diff --git a/clients/dotnet/MxGateway.Client.Cli/CliArguments.cs b/clients/dotnet/MxGateway.Client.Cli/CliArguments.cs index 31d6fa0..2da2d2a 100644 --- a/clients/dotnet/MxGateway.Client.Cli/CliArguments.cs +++ b/clients/dotnet/MxGateway.Client.Cli/CliArguments.cs @@ -2,11 +2,14 @@ using System.Globalization; namespace MxGateway.Client.Cli; +/// Parses command-line arguments into flags and named values. internal sealed class CliArguments { private readonly Dictionary _values = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _flags = new(StringComparer.OrdinalIgnoreCase); + /// Initializes a new instance by parsing the given command-line arguments. + /// Unparsed command-line arguments; flags prefixed with '--' and values follow their flag. public CliArguments(IEnumerable args) { string? pendingName = null; @@ -39,11 +42,15 @@ internal sealed class CliArguments } } + /// Returns whether the named flag was present in the arguments. + /// The flag name (without '--' prefix). public bool HasFlag(string name) { return _flags.Contains(name); } + /// Returns the value for a named argument, or null if absent. + /// The argument name (without '--' prefix). public string? GetOptional(string name) { return _values.TryGetValue(name, out string? value) @@ -51,6 +58,8 @@ internal sealed class CliArguments : null; } + /// Returns the value for a required named argument, or throws if absent. + /// The argument name (without '--' prefix). public string GetRequired(string name) { string? value = GetOptional(name); @@ -62,6 +71,9 @@ internal sealed class CliArguments return value; } + /// Parses and returns an int32 argument, or the default value if absent. + /// The argument name (without '--' prefix). + /// The default value if the argument is absent; if null, the argument is required. public int GetInt32(string name, int? defaultValue = null) { string? value = GetOptional(name); @@ -78,6 +90,9 @@ internal sealed class CliArguments return int.Parse(value, CultureInfo.InvariantCulture); } + /// Parses and returns a uint32 argument, or the default value if absent. + /// The argument name (without '--' prefix). + /// The default value if the argument is absent. public uint GetUInt32(string name, uint defaultValue) { string? value = GetOptional(name); @@ -86,6 +101,9 @@ internal sealed class CliArguments : uint.Parse(value, CultureInfo.InvariantCulture); } + /// Parses and returns a uint64 argument, or the default value if absent. + /// The argument name (without '--' prefix). + /// The default value if the argument is absent. public ulong GetUInt64(string name, ulong defaultValue) { string? value = GetOptional(name); @@ -94,6 +112,9 @@ internal sealed class CliArguments : ulong.Parse(value, CultureInfo.InvariantCulture); } + /// Parses and returns a TimeSpan argument, or the default value if absent. Supports "ms", "s", and standard TimeSpan format. + /// The argument name (without '--' prefix). + /// The default value if the argument is absent. public TimeSpan GetDuration(string name, TimeSpan defaultValue) { string? value = GetOptional(name); diff --git a/clients/dotnet/MxGateway.Client.Cli/IMxGatewayCliClient.cs b/clients/dotnet/MxGateway.Client.Cli/IMxGatewayCliClient.cs index 60c3340..ba04136 100644 --- a/clients/dotnet/MxGateway.Client.Cli/IMxGatewayCliClient.cs +++ b/clients/dotnet/MxGateway.Client.Cli/IMxGatewayCliClient.cs @@ -5,34 +5,82 @@ namespace MxGateway.Client.Cli; public interface IMxGatewayCliClient : IAsyncDisposable { + /// + /// Opens a new gateway session. + /// + /// Session open request. + /// Cancellation token for the operation. + /// The session open reply. Task OpenSessionAsync( OpenSessionRequest request, CancellationToken cancellationToken); + /// + /// Closes an open gateway session. + /// + /// Session close request. + /// Cancellation token for the operation. + /// The session close reply. Task CloseSessionAsync( CloseSessionRequest request, CancellationToken cancellationToken); + /// + /// Invokes an MXAccess command on the session. + /// + /// The command request. + /// Cancellation token for the operation. + /// The command reply. Task InvokeAsync( MxCommandRequest request, CancellationToken cancellationToken); + /// + /// Streams events from the gateway session. + /// + /// The stream events request. + /// Cancellation token for the operation. + /// An async enumerable of events. IAsyncEnumerable StreamEventsAsync( StreamEventsRequest request, CancellationToken cancellationToken); + /// + /// Tests connection to the Galaxy Repository. + /// + /// The connection test request. + /// Cancellation token for the operation. + /// The connection test reply. Task GalaxyTestConnectionAsync( TestConnectionRequest request, CancellationToken cancellationToken); + /// + /// Gets the last deployment time from the Galaxy Repository. + /// + /// The last deploy time request. + /// Cancellation token for the operation. + /// The last deploy time reply. Task GalaxyGetLastDeployTimeAsync( GetLastDeployTimeRequest request, CancellationToken cancellationToken); + /// + /// Discovers the Galaxy Repository hierarchy. + /// + /// The discover hierarchy request. + /// Cancellation token for the operation. + /// The discover hierarchy reply. Task GalaxyDiscoverHierarchyAsync( DiscoverHierarchyRequest request, CancellationToken cancellationToken); + /// + /// Watches for deployment events from the Galaxy Repository. + /// + /// The watch deploy events request. + /// Cancellation token for the operation. + /// An async enumerable of deployment events. IAsyncEnumerable GalaxyWatchDeployEventsAsync( WatchDeployEventsRequest request, CancellationToken cancellationToken); diff --git a/clients/dotnet/MxGateway.Client.Cli/MxGatewayCliClientAdapter.cs b/clients/dotnet/MxGateway.Client.Cli/MxGatewayCliClientAdapter.cs index 9fe099b..47f0d06 100644 --- a/clients/dotnet/MxGateway.Client.Cli/MxGatewayCliClientAdapter.cs +++ b/clients/dotnet/MxGateway.Client.Cli/MxGatewayCliClientAdapter.cs @@ -9,6 +9,10 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient private readonly MxGatewayClient _client; private readonly Lazy _galaxyClient; + /// + /// Initializes a new instance of the that bridges the CLI to the gateway client. + /// + /// The gateway client to adapt. public MxGatewayCliClientAdapter(MxGatewayClient client) { _client = client; @@ -16,6 +20,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient () => GalaxyRepositoryClient.Create(_client.Options)); } + /// public Task OpenSessionAsync( OpenSessionRequest request, CancellationToken cancellationToken) @@ -23,6 +28,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient return _client.OpenSessionRawAsync(request, cancellationToken); } + /// public Task CloseSessionAsync( CloseSessionRequest request, CancellationToken cancellationToken) @@ -30,6 +36,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient return _client.CloseSessionRawAsync(request, cancellationToken); } + /// public Task InvokeAsync( MxCommandRequest request, CancellationToken cancellationToken) @@ -37,6 +44,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient return _client.InvokeAsync(request, cancellationToken); } + /// public IAsyncEnumerable StreamEventsAsync( StreamEventsRequest request, CancellationToken cancellationToken) @@ -44,6 +52,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient return _client.StreamEventsAsync(request, cancellationToken); } + /// public Task GalaxyTestConnectionAsync( TestConnectionRequest request, CancellationToken cancellationToken) @@ -51,6 +60,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient return _galaxyClient.Value.TestConnectionRawAsync(request, cancellationToken); } + /// public Task GalaxyGetLastDeployTimeAsync( GetLastDeployTimeRequest request, CancellationToken cancellationToken) @@ -58,6 +68,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient return _galaxyClient.Value.GetLastDeployTimeRawAsync(request, cancellationToken); } + /// public Task GalaxyDiscoverHierarchyAsync( DiscoverHierarchyRequest request, CancellationToken cancellationToken) @@ -65,6 +76,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient return _galaxyClient.Value.DiscoverHierarchyRawAsync(request, cancellationToken); } + /// public IAsyncEnumerable GalaxyWatchDeployEventsAsync( WatchDeployEventsRequest request, CancellationToken cancellationToken) @@ -72,6 +84,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken); } + /// public async ValueTask DisposeAsync() { if (_galaxyClient.IsValueCreated) diff --git a/clients/dotnet/MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs b/clients/dotnet/MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs index 42a6a96..1feada4 100644 --- a/clients/dotnet/MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs +++ b/clients/dotnet/MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs @@ -1,7 +1,11 @@ namespace MxGateway.Client.Cli; +/// Utility to redact API keys from error messages for safe output. internal static class MxGatewayCliSecretRedactor { + /// Replaces occurrences of the API key in the value with a redacted placeholder. + /// The message text to redact. + /// The API key to remove; no redaction if null or empty. public static string Redact(string value, string? apiKey) { if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey)) diff --git a/clients/dotnet/MxGateway.Client.Cli/MxGatewayClientCli.cs b/clients/dotnet/MxGateway.Client.Cli/MxGatewayClientCli.cs index c24f24e..54723e1 100644 --- a/clients/dotnet/MxGateway.Client.Cli/MxGatewayClientCli.cs +++ b/clients/dotnet/MxGateway.Client.Cli/MxGatewayClientCli.cs @@ -7,6 +7,7 @@ using MxGateway.Contracts.Proto.Galaxy; namespace MxGateway.Client.Cli; +/// Command-line interface for the MXAccess Gateway client, supporting session and command operations. public static class MxGatewayClientCli { private const uint MaxAggregateEvents = 10_000; @@ -15,6 +16,10 @@ public static class MxGatewayClientCli private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + /// Runs the CLI synchronously with the given arguments, writing output and errors. + /// Command-line arguments (command name followed by options). + /// TextWriter for command output. + /// TextWriter for error messages. public static int Run( string[] args, TextWriter standardOutput, @@ -25,6 +30,11 @@ public static class MxGatewayClientCli .GetResult(); } + /// Runs the CLI asynchronously with the given arguments, writing output and errors. + /// Command-line arguments (command name followed by options). + /// TextWriter for command output. + /// TextWriter for error messages. + /// Optional factory to create the gateway client; defaults to MxGatewayClient.Create. public static Task RunAsync( string[] args, TextWriter standardOutput, diff --git a/clients/dotnet/MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs b/clients/dotnet/MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs index 8053aba..4e9c912 100644 --- a/clients/dotnet/MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs +++ b/clients/dotnet/MxGateway.Client.Tests/FakeGalaxyRepositoryTransport.cs @@ -3,30 +3,71 @@ using MxGateway.Contracts.Proto.Galaxy; namespace MxGateway.Client.Tests; +/// +/// Fake Galaxy Repository client transport for testing. +/// internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport { + /// + /// Gets the gateway client options. + /// public MxGatewayClientOptions Options { get; } = options; + /// + /// Gets the raw gRPC client; always null for the fake. + /// public GalaxyRepository.GalaxyRepositoryClient? RawClient => null; + /// + /// Gets the list of TestConnection RPC calls made by the client. + /// public List<(TestConnectionRequest Request, CallOptions CallOptions)> TestConnectionCalls { get; } = []; + /// + /// Gets the list of GetLastDeployTime RPC calls made by the client. + /// public List<(GetLastDeployTimeRequest Request, CallOptions CallOptions)> GetLastDeployTimeCalls { get; } = []; + /// + /// Gets the list of DiscoverHierarchy RPC calls made by the client. + /// public List<(DiscoverHierarchyRequest Request, CallOptions CallOptions)> DiscoverHierarchyCalls { get; } = []; + /// + /// Gets or sets the reply to return from TestConnection; defaults to successful response. + /// public TestConnectionReply TestConnectionReply { get; set; } = new() { Ok = true }; + /// + /// Gets or sets the reply to return from GetLastDeployTime; defaults to no deploy time present. + /// public GetLastDeployTimeReply GetLastDeployTimeReply { get; set; } = new() { Present = false }; + /// + /// Gets or sets the reply to return from DiscoverHierarchy; defaults to empty response. + /// public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new(); + /// + /// Gets the queue of exceptions to throw from TestConnection; dequeued in FIFO order. + /// public Queue TestConnectionExceptions { get; } = new(); + /// + /// Gets the queue of exceptions to throw from GetLastDeployTime; dequeued in FIFO order. + /// public Queue GetLastDeployTimeExceptions { get; } = new(); + /// + /// Gets the queue of exceptions to throw from DiscoverHierarchy; dequeued in FIFO order. + /// public Queue DiscoverHierarchyExceptions { get; } = new(); + /// + /// Records the request and either throws a queued exception or returns the configured reply. + /// + /// The TestConnectionRequest to process. + /// Call options specifying RPC behavior. public Task TestConnectionAsync( TestConnectionRequest request, CallOptions callOptions) @@ -40,6 +81,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio return Task.FromResult(TestConnectionReply); } + /// + /// Records the request and either throws a queued exception or returns the configured reply. + /// + /// The GetLastDeployTimeRequest to process. + /// Call options specifying RPC behavior. public Task GetLastDeployTimeAsync( GetLastDeployTimeRequest request, CallOptions callOptions) @@ -53,6 +99,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio return Task.FromResult(GetLastDeployTimeReply); } + /// + /// Records the request and either throws a queued exception or returns the configured reply. + /// + /// The DiscoverHierarchyRequest to process. + /// Call options specifying RPC behavior. public Task DiscoverHierarchyAsync( DiscoverHierarchyRequest request, CallOptions callOptions) @@ -66,10 +117,19 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio return Task.FromResult(DiscoverHierarchyReply); } + /// + /// Gets the list of WatchDeployEvents RPC calls made by the client. + /// public List<(WatchDeployEventsRequest Request, CallOptions CallOptions)> WatchDeployEventsCalls { get; } = []; + /// + /// Gets or sets the list of events to stream from WatchDeployEvents. + /// public List WatchDeployEvents { get; } = []; + /// + /// Gets or sets the exception to throw from WatchDeployEvents, if any. + /// public Exception? WatchDeployEventsException { get; set; } /// @@ -78,6 +138,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio /// public Func? WatchDeployEventsBeforeYield { get; set; } + /// + /// Records the request and streams events, checking for queued exceptions and calling WatchDeployEventsBeforeYield before each event. + /// + /// The WatchDeployEventsRequest to process. + /// Call options specifying RPC behavior. public async IAsyncEnumerable WatchDeployEventsAsync( WatchDeployEventsRequest request, CallOptions callOptions) diff --git a/clients/dotnet/MxGateway.Client.Tests/FakeGatewayTransport.cs b/clients/dotnet/MxGateway.Client.Tests/FakeGatewayTransport.cs index f5163bd..9f27e5c 100644 --- a/clients/dotnet/MxGateway.Client.Tests/FakeGatewayTransport.cs +++ b/clients/dotnet/MxGateway.Client.Tests/FakeGatewayTransport.cs @@ -3,23 +3,47 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Client.Tests; +/// +/// Fake implementation of IMxGatewayClientTransport for testing. +/// internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMxGatewayClientTransport { private readonly Queue _invokeReplies = new(); private readonly List _events = []; + /// + /// Gets the gateway client options. + /// public MxGatewayClientOptions Options { get; } = options; + /// + /// Gets null, since this is a test fake without a real gRPC client. + /// public MxAccessGateway.MxAccessGatewayClient? RawClient => null; + /// + /// Gets the list of captured OpenSessionAsync calls. + /// public List<(OpenSessionRequest Request, CallOptions CallOptions)> OpenSessionCalls { get; } = []; + /// + /// Gets the list of captured CloseSessionAsync calls. + /// public List<(CloseSessionRequest Request, CallOptions CallOptions)> CloseSessionCalls { get; } = []; + /// + /// Gets the list of captured InvokeAsync calls. + /// public List<(MxCommandRequest Request, CallOptions CallOptions)> InvokeCalls { get; } = []; + /// + /// Gets the list of captured StreamEventsAsync calls. + /// public List<(StreamEventsRequest Request, CallOptions CallOptions)> StreamEventsCalls { get; } = []; + /// + /// Gets or sets the reply to return from OpenSessionAsync. + /// public OpenSessionReply OpenSessionReply { get; set; } = new() { SessionId = "session-fixture", @@ -29,6 +53,9 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, }; + /// + /// Gets or sets the reply to return from CloseSessionAsync. + /// public CloseSessionReply CloseSessionReply { get; set; } = new() { SessionId = "session-fixture", @@ -36,12 +63,26 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok }, }; + /// + /// Gets the queue of exceptions to throw from OpenSessionAsync. + /// public Queue OpenSessionExceptions { get; } = new(); + /// + /// Gets the queue of exceptions to throw from CloseSessionAsync. + /// public Queue CloseSessionExceptions { get; } = new(); + /// + /// Gets the queue of exceptions to throw from InvokeAsync. + /// public Queue InvokeExceptions { get; } = new(); + /// + /// Verifies that the OpenSessionAsync call is recorded and returns the configured reply. + /// + /// The OpenSessionRequest to process. + /// Call options specifying RPC behavior. public Task OpenSessionAsync( OpenSessionRequest request, CallOptions callOptions) @@ -55,6 +96,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx return Task.FromResult(OpenSessionReply); } + /// + /// Verifies that the CloseSessionAsync call is recorded and returns the configured reply. + /// + /// The CloseSessionRequest to process. + /// Call options specifying RPC behavior. public Task CloseSessionAsync( CloseSessionRequest request, CallOptions callOptions) @@ -68,6 +114,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx return Task.FromResult(CloseSessionReply); } + /// + /// Verifies that the InvokeAsync call is recorded and returns the next enqueued reply. + /// + /// The MxCommandRequest to process. + /// Call options specifying RPC behavior. public Task InvokeAsync( MxCommandRequest request, CallOptions callOptions) @@ -81,6 +132,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx return Task.FromResult(_invokeReplies.Dequeue()); } + /// + /// Verifies that the StreamEventsAsync call is recorded and yields all enqueued events. + /// + /// The StreamEventsRequest to process. + /// Call options specifying RPC behavior. public async IAsyncEnumerable StreamEventsAsync( StreamEventsRequest request, CallOptions callOptions) @@ -95,11 +151,19 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx } } + /// + /// Enqueues a reply to be returned from the next InvokeAsync call. + /// + /// The reply to enqueue. public void AddInvokeReply(MxCommandReply reply) { _invokeReplies.Enqueue(reply); } + /// + /// Enqueues an event to be yielded from StreamEventsAsync. + /// + /// The event to enqueue. public void AddEvent(MxEvent gatewayEvent) { _events.Add(gatewayEvent); diff --git a/clients/dotnet/MxGateway.Client.Tests/GalaxyRepositoryClientTests.cs b/clients/dotnet/MxGateway.Client.Tests/GalaxyRepositoryClientTests.cs index 4ffd875..4c0ecfb 100644 --- a/clients/dotnet/MxGateway.Client.Tests/GalaxyRepositoryClientTests.cs +++ b/clients/dotnet/MxGateway.Client.Tests/GalaxyRepositoryClientTests.cs @@ -6,6 +6,9 @@ namespace MxGateway.Client.Tests; public sealed class GalaxyRepositoryClientTests { + /// + /// Verifies that TestConnectionAsync attaches the API key in request metadata and returns the Ok flag. + /// [Fact] public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag() { @@ -21,6 +24,9 @@ public sealed class GalaxyRepositoryClientTests Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization")); } + /// + /// Verifies that TestConnectionAsync returns false when the server reports NotOk. + /// [Fact] public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk() { @@ -33,6 +39,9 @@ public sealed class GalaxyRepositoryClientTests Assert.False(ok); } + /// + /// Verifies that GetLastDeployTimeAsync returns null when the server reports not present. + /// [Fact] public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent() { @@ -46,6 +55,9 @@ public sealed class GalaxyRepositoryClientTests Assert.Single(transport.GetLastDeployTimeCalls); } + /// + /// Verifies that GetLastDeployTimeAsync returns the timestamp when the server reports it present. + /// [Fact] public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent() { @@ -64,6 +76,9 @@ public sealed class GalaxyRepositoryClientTests Assert.Equal(expected, deployTime!.Value); } + /// + /// Verifies that DiscoverHierarchyAsync returns the objects from the server reply. + /// [Fact] public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply() { @@ -104,6 +119,9 @@ public sealed class GalaxyRepositoryClientTests Assert.Equal("DelmiaReceiver_001.DownloadPath", attribute.FullTagReference); } + /// + /// Verifies that DiscoverHierarchyAsync propagates cancellation tokens to the transport. + /// [Fact] public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport() { @@ -121,6 +139,9 @@ public sealed class GalaxyRepositoryClientTests Assert.False(call.CallOptions.CancellationToken.IsCancellationRequested); } + /// + /// Verifies that TestConnectionAsync retries on transient gRPC failures. + /// [Fact] public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure() { @@ -135,6 +156,9 @@ public sealed class GalaxyRepositoryClientTests Assert.Equal(2, transport.TestConnectionCalls.Count); } + /// + /// Verifies that DiscoverHierarchyAsync retries on transient gRPC failures. + /// [Fact] public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure() { @@ -148,6 +172,9 @@ public sealed class GalaxyRepositoryClientTests Assert.Equal(2, transport.DiscoverHierarchyCalls.Count); } + /// + /// Verifies that WatchDeployEventsAsync delivers the bootstrap event. + /// [Fact] public async Task WatchDeployEventsAsync_DeliversBootstrapEvent() { @@ -181,6 +208,9 @@ public sealed class GalaxyRepositoryClientTests Assert.Null(call.Request.LastSeenDeployTime); } + /// + /// Verifies that WatchDeployEventsAsync delivers multiple events in order. + /// [Fact] public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder() { @@ -216,6 +246,9 @@ public sealed class GalaxyRepositoryClientTests Assert.Equal(t0, call.Request.LastSeenDeployTime!.ToDateTime()); } + /// + /// Verifies that WatchDeployEventsAsync stops iteration cleanly when cancelled. + /// [Fact] public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly() { @@ -257,6 +290,9 @@ public sealed class GalaxyRepositoryClientTests Assert.Equal(1ul, received[0].Sequence); } + /// + /// Verifies that WatchDeployEventsAsync throws ObjectDisposedException after the client is disposed. + /// [Fact] public async Task WatchDeployEventsAsync_ThrowsAfterDisposal() { @@ -269,6 +305,9 @@ public sealed class GalaxyRepositoryClientTests client.WatchDeployEventsAsync()); } + /// + /// Verifies that TestConnectionAsync throws ObjectDisposedException after the client is disposed. + /// [Fact] public async Task TestConnectionAsync_ThrowsAfterDisposal() { diff --git a/clients/dotnet/MxGateway.Client.Tests/MxCommandReplyExtensionsTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxCommandReplyExtensionsTests.cs index 686c1e4..d10761b 100644 --- a/clients/dotnet/MxGateway.Client.Tests/MxCommandReplyExtensionsTests.cs +++ b/clients/dotnet/MxGateway.Client.Tests/MxCommandReplyExtensionsTests.cs @@ -6,6 +6,7 @@ namespace MxGateway.Client.Tests; public sealed class MxCommandReplyExtensionsTests { + /// Verifies that successful replies pass both protocol and MxAccess success checks. [Fact] public void EnsureSuccess_WithRegisterFixture_ReturnsReply() { @@ -15,6 +16,7 @@ public sealed class MxCommandReplyExtensionsTests Assert.Same(reply, reply.EnsureMxAccessSuccess()); } + /// Verifies that MxAccess failures throw with preserved HResult and status details. [Fact] public void EnsureMxAccessSuccess_WithFailureFixture_PreservesHResultAndStatuses() { @@ -30,6 +32,7 @@ public sealed class MxCommandReplyExtensionsTests Assert.Contains("0x80040200", exception.Message); } + /// Verifies that session-not-found protocol failures throw the correct gateway exception. [Fact] public void EnsureProtocolSuccess_WithSessionFailure_ThrowsSessionException() { diff --git a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs index 5eda13d..e293ee4 100644 --- a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs +++ b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientCliTests.cs @@ -5,8 +5,10 @@ using MxGateway.Contracts.Proto.Galaxy; namespace MxGateway.Client.Tests; +/// Tests for the CLI command interface. public sealed class MxGatewayClientCliTests { + /// Verifies that the version command prints compiled protocol versions. [Fact] public void Run_Version_PrintsCompiledProtocolVersions() { @@ -21,6 +23,7 @@ public sealed class MxGatewayClientCliTests Assert.Equal(string.Empty, error.ToString()); } + /// Verifies that the version command with --json flag prints JSON protocol versions. [Fact] public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions() { @@ -34,6 +37,7 @@ public sealed class MxGatewayClientCliTests Assert.Equal(string.Empty, error.ToString()); } + /// Verifies that the write command builds a write request and prints JSON reply. [Fact] public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply() { @@ -78,6 +82,7 @@ public sealed class MxGatewayClientCliTests Assert.Equal(string.Empty, error.ToString()); } + /// Verifies that error output redacts sensitive API key values. [Fact] public async Task RunAsync_ErrorOutput_RedactsApiKey() { @@ -101,6 +106,7 @@ public sealed class MxGatewayClientCliTests Assert.Contains("[redacted]", error.ToString()); } + /// Verifies that stream-events with max-events limit stops output in non-JSON format. [Fact] public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput() { @@ -142,6 +148,7 @@ public sealed class MxGatewayClientCliTests } + /// Verifies that smoke command closes opened session when a command fails. [Fact] public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession() { @@ -172,6 +179,7 @@ public sealed class MxGatewayClientCliTests Assert.Equal("session-fixture", closeRequest.SessionId); } + /// Verifies that galaxy-test-connection command prints JSON reply. [Fact] public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply() { @@ -201,6 +209,7 @@ public sealed class MxGatewayClientCliTests Assert.Equal(string.Empty, error.ToString()); } + /// Verifies that galaxy-discover command prints hierarchy summary. [Fact] public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary() { @@ -250,6 +259,7 @@ public sealed class MxGatewayClientCliTests Assert.Equal(string.Empty, error.ToString()); } + /// Verifies that galaxy-watch command prints text output for deploy events. [Fact] public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents() { @@ -303,6 +313,7 @@ public sealed class MxGatewayClientCliTests Assert.Equal(string.Empty, error.ToString()); } + /// Verifies that galaxy-watch with --json emits one JSON object per event. [Fact] public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent() { @@ -337,23 +348,31 @@ public sealed class MxGatewayClientCliTests Assert.Contains("\"objectCount\": 99", text); } + /// Fake CLI client for testing. private sealed class FakeCliClient : IMxGatewayCliClient { + /// Queue of invoke replies to return. public Queue InvokeReplies { get; } = new(); + /// List of received invoke requests. public List InvokeRequests { get; } = []; + /// List of received close session requests. public List CloseSessionRequests { get; } = []; + /// List of events to yield when streaming. public List Events { get; } = []; + /// Exception to throw on invoke, if any. public Exception? InvokeFailure { get; init; } + /// public ValueTask DisposeAsync() { return ValueTask.CompletedTask; } + /// public Task OpenSessionAsync( OpenSessionRequest request, CancellationToken cancellationToken) @@ -367,6 +386,7 @@ public sealed class MxGatewayClientCliTests }); } + /// public Task CloseSessionAsync( CloseSessionRequest request, CancellationToken cancellationToken) @@ -380,6 +400,7 @@ public sealed class MxGatewayClientCliTests }); } + /// public Task InvokeAsync( MxCommandRequest request, CancellationToken cancellationToken) @@ -393,6 +414,7 @@ public sealed class MxGatewayClientCliTests return Task.FromResult(InvokeReplies.Dequeue()); } + /// public async IAsyncEnumerable StreamEventsAsync( StreamEventsRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) @@ -405,18 +427,25 @@ public sealed class MxGatewayClientCliTests } } + /// Galaxy test connection reply to return. public TestConnectionReply GalaxyTestConnectionReply { get; set; } = new() { Ok = true }; + /// Galaxy get last deploy time reply to return. public GetLastDeployTimeReply GalaxyGetLastDeployTimeReply { get; set; } = new() { Present = false }; + /// Galaxy discover hierarchy reply to return. public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new(); + /// List of received galaxy test connection requests. public List GalaxyTestConnectionRequests { get; } = []; + /// List of received galaxy get last deploy time requests. public List GalaxyGetLastDeployTimeRequests { get; } = []; + /// List of received galaxy discover hierarchy requests. public List GalaxyDiscoverHierarchyRequests { get; } = []; + /// public Task GalaxyTestConnectionAsync( TestConnectionRequest request, CancellationToken cancellationToken) @@ -425,6 +454,7 @@ public sealed class MxGatewayClientCliTests return Task.FromResult(GalaxyTestConnectionReply); } + /// public Task GalaxyGetLastDeployTimeAsync( GetLastDeployTimeRequest request, CancellationToken cancellationToken) @@ -433,6 +463,7 @@ public sealed class MxGatewayClientCliTests return Task.FromResult(GalaxyGetLastDeployTimeReply); } + /// public Task GalaxyDiscoverHierarchyAsync( DiscoverHierarchyRequest request, CancellationToken cancellationToken) @@ -441,10 +472,13 @@ public sealed class MxGatewayClientCliTests return Task.FromResult(GalaxyDiscoverHierarchyReply); } + /// List of received galaxy watch deploy events requests. public List GalaxyWatchDeployEventsRequests { get; } = []; + /// List of deploy events to yield when watching. public List GalaxyDeployEvents { get; } = []; + /// public async IAsyncEnumerable GalaxyWatchDeployEventsAsync( WatchDeployEventsRequest request, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) diff --git a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientContractInfoTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientContractInfoTests.cs index 1458347..ce4d4d2 100644 --- a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientContractInfoTests.cs +++ b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientContractInfoTests.cs @@ -4,6 +4,7 @@ namespace MxGateway.Client.Tests; public sealed class MxGatewayClientContractInfoTests { + /// Verifies that the client's gateway protocol version matches the shared contract definition. [Fact] public void GatewayProtocolVersion_MatchesSharedContract() { @@ -12,6 +13,7 @@ public sealed class MxGatewayClientContractInfoTests MxGatewayClientContractInfo.GatewayProtocolVersion); } + /// Verifies that the client's worker protocol version matches the shared contract definition. [Fact] public void WorkerProtocolVersion_MatchesSharedContract() { diff --git a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientOptionsTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientOptionsTests.cs index c019a52..cb9268c 100644 --- a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientOptionsTests.cs +++ b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientOptionsTests.cs @@ -2,6 +2,7 @@ namespace MxGateway.Client.Tests; public sealed class MxGatewayClientOptionsTests { + /// Verifies that options with valid endpoint and API key pass validation. [Fact] public void Validate_WithAbsoluteEndpointAndApiKey_Succeeds() { @@ -14,6 +15,7 @@ public sealed class MxGatewayClientOptionsTests options.Validate(); } + /// Verifies that empty API key causes validation to fail. [Fact] public void Validate_WithEmptyApiKey_Throws() { @@ -26,6 +28,7 @@ public sealed class MxGatewayClientOptionsTests Assert.Throws(options.Validate); } + /// Verifies that invalid retry options cause validation to fail. [Fact] public void Validate_WithInvalidRetryOptions_Throws() { diff --git a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientSessionTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientSessionTests.cs index b8d0f58..7764bc6 100644 --- a/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientSessionTests.cs +++ b/clients/dotnet/MxGateway.Client.Tests/MxGatewayClientSessionTests.cs @@ -3,8 +3,10 @@ using Grpc.Core; namespace MxGateway.Client.Tests; +/// Tests for MxGatewaySession and client command behavior. public sealed class MxGatewayClientSessionTests { + /// Verifies that open session attaches API key metadata and cancellation token. [Fact] public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation() { @@ -19,6 +21,7 @@ public sealed class MxGatewayClientSessionTests Assert.Equal(cancellation.Token, call.CallOptions.CancellationToken); } + /// Verifies that open session returns a session with the raw open reply. [Fact] public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply() { @@ -33,6 +36,7 @@ public sealed class MxGatewayClientSessionTests Assert.Equal(1234, session.OpenSessionReply.WorkerProcessId); } + /// Verifies that register builds a register command and returns server handle. [Fact] public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle() { @@ -57,6 +61,7 @@ public sealed class MxGatewayClientSessionTests Assert.Equal("fixture-client", call.Request.Command.Register.ClientName); } + /// Verifies that add item 2 builds a command with the specified context. [Fact] public async Task AddItem2Async_BuildsAddItem2CommandWithContext() { @@ -81,6 +86,7 @@ public sealed class MxGatewayClientSessionTests Assert.Equal("runtime", request.Command.AddItem2.ItemContext); } + /// Verifies that write raw builds a write command with the raw value. [Fact] public async Task WriteRawAsync_BuildsWriteCommandWithRawValue() { @@ -111,6 +117,7 @@ public sealed class MxGatewayClientSessionTests Assert.Equal(56, request.Command.Write.UserId); } + /// Verifies that write 2 raw builds a write 2 command with value and timestamp. [Fact] public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp() { @@ -138,6 +145,7 @@ public sealed class MxGatewayClientSessionTests Assert.Equal(56, request.Command.Write2.UserId); } + /// Verifies that subscribe bulk builds one command and returns per-item results. [Fact] public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults() { @@ -176,6 +184,7 @@ public sealed class MxGatewayClientSessionTests Assert.Equal(["Area001.Pump001.Speed"], request.Command.SubscribeBulk.TagAddresses); } + /// Verifies that stream events yields events in the order received from the gateway. [Fact] public async Task StreamEventsAsync_YieldsEventsInGatewayOrder() { @@ -206,6 +215,7 @@ public sealed class MxGatewayClientSessionTests Assert.Equal("session-fixture", request.SessionId); } + /// Verifies that close is explicit and idempotent. [Fact] public async Task CloseAsync_IsExplicitAndIdempotent() { @@ -221,6 +231,7 @@ public sealed class MxGatewayClientSessionTests Assert.Equal("session-fixture", call.Request.SessionId); } + /// Verifies that invoke retries safe diagnostic commands on transient RPC failure. [Fact] public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure() { @@ -244,6 +255,7 @@ public sealed class MxGatewayClientSessionTests Assert.Equal(2, transport.InvokeCalls.Count); } + /// Verifies that open session does not retry on transient RPC failure. [Fact] public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure() { @@ -256,6 +268,7 @@ public sealed class MxGatewayClientSessionTests Assert.Single(transport.OpenSessionCalls); } + /// Verifies that invoke does not retry write commands on transient RPC failure. [Fact] public async Task InvokeAsync_DoesNotRetryWriteCommand() { @@ -270,6 +283,7 @@ public sealed class MxGatewayClientSessionTests Assert.Single(transport.InvokeCalls); } + /// Verifies that invoke helpers pass cancellation token to the transport. [Fact] public async Task InvokeHelpers_PassCancellationTokenToTransport() { diff --git a/clients/dotnet/MxGateway.Client.Tests/MxGatewayGeneratedContractTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxGatewayGeneratedContractTests.cs index 846d9e3..be0d0b0 100644 --- a/clients/dotnet/MxGateway.Client.Tests/MxGatewayGeneratedContractTests.cs +++ b/clients/dotnet/MxGateway.Client.Tests/MxGatewayGeneratedContractTests.cs @@ -2,6 +2,7 @@ namespace MxGateway.Client.Tests; public sealed class MxGatewayGeneratedContractTests { + /// Verifies that the generated gRPC client can be instantiated from the client factory. [Fact] public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory() { diff --git a/clients/dotnet/MxGateway.Client.Tests/MxStatusProxyExtensionsTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxStatusProxyExtensionsTests.cs index 76ecca8..61fd2fb 100644 --- a/clients/dotnet/MxGateway.Client.Tests/MxStatusProxyExtensionsTests.cs +++ b/clients/dotnet/MxGateway.Client.Tests/MxStatusProxyExtensionsTests.cs @@ -7,6 +7,7 @@ namespace MxGateway.Client.Tests; public sealed class MxStatusProxyExtensionsTests { + /// Verifies that fixture statuses correctly project success and preserve raw integer fields. [Fact] public void FixtureStatuses_ProjectSuccessAndPreserveRawFields() { diff --git a/clients/dotnet/MxGateway.Client.Tests/MxValueExtensionsTests.cs b/clients/dotnet/MxGateway.Client.Tests/MxValueExtensionsTests.cs index 2fbde41..9b318e5 100644 --- a/clients/dotnet/MxGateway.Client.Tests/MxValueExtensionsTests.cs +++ b/clients/dotnet/MxGateway.Client.Tests/MxValueExtensionsTests.cs @@ -7,6 +7,7 @@ namespace MxGateway.Client.Tests; public sealed class MxValueExtensionsTests { + /// Verifies that scalar values are converted to correctly-typed MxValue protobuf messages. [Fact] public void ToMxValue_WithScalarValues_CreatesTypedProtobufValues() { @@ -18,6 +19,7 @@ public sealed class MxValueExtensionsTests Assert.Equal(MxValue.KindOneofCase.StringValue, "alpha".ToMxValue().KindCase); } + /// Verifies that array values are converted to array-kind MxValue messages with correct element types and dimensions. [Fact] public void ToMxValue_WithArrays_CreatesTypedArrayProtobufValues() { @@ -29,6 +31,7 @@ public sealed class MxValueExtensionsTests Assert.Equal([2U], value.ArrayValue.Dimensions); } + /// Verifies that fixture test cases project to expected MxValue kinds and preserve raw type metadata. [Fact] public void FixtureValues_ProjectExpectedKindsAndPreserveRawMetadata() { diff --git a/clients/dotnet/MxGateway.Client/GalaxyRepositoryClient.cs b/clients/dotnet/MxGateway.Client/GalaxyRepositoryClient.cs index f9568cc..4894af8 100644 --- a/clients/dotnet/MxGateway.Client/GalaxyRepositoryClient.cs +++ b/clients/dotnet/MxGateway.Client/GalaxyRepositoryClient.cs @@ -23,6 +23,11 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable private readonly ResiliencePipeline _safeUnaryRetryPipeline; private bool _disposed; + /// + /// Initializes a Galaxy Repository client with custom transport and options. + /// + /// Client options. + /// The underlying gRPC transport. internal GalaxyRepositoryClient( MxGatewayClientOptions options, IGalaxyRepositoryClientTransport transport) @@ -50,12 +55,23 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable Options.LoggerFactory?.CreateLogger()); } + /// + /// Client options used to configure timeouts, authentication, and retry policy. + /// public MxGatewayClientOptions Options { get; } + /// + /// The underlying generated gRPC client for advanced operations. + /// public GalaxyRepository.GalaxyRepositoryClient RawClient => _transport.RawClient ?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance."); + /// + /// Creates a Galaxy Repository client with the given options, establishing a new gRPC channel. + /// + /// Client options. + /// A new client instance. public static GalaxyRepositoryClient Create(MxGatewayClientOptions options) { ArgumentNullException.ThrowIfNull(options); @@ -81,6 +97,8 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable /// Probes the Galaxy Repository database connection. Returns true when the /// gateway can reach the configured ZB SQL Server. /// + /// Cancellation token. + /// True if connection is successful, false otherwise. public async Task TestConnectionAsync(CancellationToken cancellationToken = default) { TestConnectionReply reply = await TestConnectionRawAsync( @@ -91,6 +109,12 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable return reply.Ok; } + /// + /// Probes the Galaxy Repository database connection without result wrapping. + /// + /// The test connection request. + /// Cancellation token. + /// The raw server reply. public Task TestConnectionRawAsync( TestConnectionRequest request, CancellationToken cancellationToken = default) @@ -107,6 +131,8 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable /// Returns the timestamp of the most recent Galaxy deployment, or /// when no deployment has been recorded. /// + /// Cancellation token. + /// The deployment timestamp, or null if not recorded. public async Task GetLastDeployTimeAsync(CancellationToken cancellationToken = default) { GetLastDeployTimeReply reply = await GetLastDeployTimeRawAsync( @@ -122,6 +148,12 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable return reply.TimeOfLastDeploy.ToDateTime(); } + /// + /// Returns the most recent Galaxy deployment timestamp without result wrapping. + /// + /// The last deploy-time request. + /// Cancellation token. + /// The raw server reply. public Task GetLastDeployTimeRawAsync( GetLastDeployTimeRequest request, CancellationToken cancellationToken = default) @@ -139,6 +171,8 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable /// includes its dynamic attributes so callers can determine which tag references /// they may subscribe to via the MxAccessGateway service. /// + /// Cancellation token. + /// The collection of Galaxy objects in the hierarchy. public async Task> DiscoverHierarchyAsync(CancellationToken cancellationToken = default) { DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync( @@ -149,6 +183,12 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable return reply.Objects; } + /// + /// Enumerates the Galaxy object hierarchy without result wrapping. + /// + /// The discover-hierarchy request. + /// Cancellation token. + /// The raw server reply. public Task DiscoverHierarchyRawAsync( DiscoverHierarchyRequest request, CancellationToken cancellationToken = default) @@ -173,6 +213,9 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable /// at-least-once delivery beyond the per-subscriber buffer (gaps in /// indicate dropped events). /// + /// Optional timestamp to suppress the bootstrap event. + /// Cancellation token. + /// An async enumerable of deploy events. public IAsyncEnumerable WatchDeployEventsAsync( DateTimeOffset? lastSeenDeployTime = null, CancellationToken cancellationToken = default) @@ -188,6 +231,12 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable return WatchDeployEventsRawAsync(request, cancellationToken); } + /// + /// Subscribes to Galaxy deploy events without result wrapping. + /// + /// The watch-deploy-events request. + /// Cancellation token. + /// An async enumerable of raw deploy events. public IAsyncEnumerable WatchDeployEventsRawAsync( WatchDeployEventsRequest request, CancellationToken cancellationToken = default) @@ -211,6 +260,9 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable } } + /// + /// Closes the gRPC channel and releases resources. + /// public ValueTask DisposeAsync() { if (_disposed) @@ -223,16 +275,32 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable return ValueTask.CompletedTask; } + /// + /// Creates gRPC call options with the client's default timeout and API-key authorization. + /// + /// Cancellation token. + /// The call options. internal CallOptions CreateCallOptions(CancellationToken cancellationToken) { return CreateCallOptions(cancellationToken, Options.DefaultCallTimeout); } + /// + /// Creates gRPC call options for streaming RPCs with the stream timeout and API-key authorization. + /// + /// Cancellation token. + /// The stream call options. internal CallOptions CreateStreamCallOptions(CancellationToken cancellationToken) { return CreateCallOptions(cancellationToken, Options.StreamTimeout); } + /// + /// Creates gRPC call options with the specified timeout and API-key authorization. + /// + /// Cancellation token. + /// Optional timeout duration. + /// The call options. internal CallOptions CreateCallOptions( CancellationToken cancellationToken, TimeSpan? timeout) diff --git a/clients/dotnet/MxGateway.Client/GrpcGalaxyRepositoryClientTransport.cs b/clients/dotnet/MxGateway.Client/GrpcGalaxyRepositoryClientTransport.cs index 67931b5..e6fa05d 100644 --- a/clients/dotnet/MxGateway.Client/GrpcGalaxyRepositoryClientTransport.cs +++ b/clients/dotnet/MxGateway.Client/GrpcGalaxyRepositoryClientTransport.cs @@ -3,16 +3,27 @@ using MxGateway.Contracts.Proto.Galaxy; namespace MxGateway.Client; +/// +/// gRPC implementation of IGalaxyRepositoryClientTransport. +/// internal sealed class GrpcGalaxyRepositoryClientTransport( MxGatewayClientOptions options, GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport { + /// + /// Gets the gateway client options. + /// public MxGatewayClientOptions Options { get; } = options; + /// + /// Gets the underlying gRPC client. + /// public GalaxyRepository.GalaxyRepositoryClient RawClient { get; } = rawClient; + /// GalaxyRepository.GalaxyRepositoryClient? IGalaxyRepositoryClientTransport.RawClient => RawClient; + /// public async Task TestConnectionAsync( TestConnectionRequest request, CallOptions callOptions) @@ -29,6 +40,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport( } } + /// public async Task GetLastDeployTimeAsync( GetLastDeployTimeRequest request, CallOptions callOptions) @@ -45,6 +57,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport( } } + /// public async Task DiscoverHierarchyAsync( DiscoverHierarchyRequest request, CallOptions callOptions) @@ -61,6 +74,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport( } } + /// public async IAsyncEnumerable WatchDeployEventsAsync( WatchDeployEventsRequest request, CallOptions callOptions, @@ -94,6 +108,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport( } } + /// IAsyncEnumerable IGalaxyRepositoryClientTransport.WatchDeployEventsAsync( WatchDeployEventsRequest request, CallOptions callOptions) diff --git a/clients/dotnet/MxGateway.Client/GrpcMxGatewayClientTransport.cs b/clients/dotnet/MxGateway.Client/GrpcMxGatewayClientTransport.cs index c196a6f..f2ea77c 100644 --- a/clients/dotnet/MxGateway.Client/GrpcMxGatewayClientTransport.cs +++ b/clients/dotnet/MxGateway.Client/GrpcMxGatewayClientTransport.cs @@ -3,16 +3,27 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Client; +/// +/// gRPC implementation of IMxGatewayClientTransport. +/// internal sealed class GrpcMxGatewayClientTransport( MxGatewayClientOptions options, MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport { + /// + /// Gets the gateway client options. + /// public MxGatewayClientOptions Options { get; } = options; + /// + /// Gets the underlying gRPC client. + /// public MxAccessGateway.MxAccessGatewayClient RawClient { get; } = rawClient; + /// MxAccessGateway.MxAccessGatewayClient? IMxGatewayClientTransport.RawClient => RawClient; + /// public async Task OpenSessionAsync( OpenSessionRequest request, CallOptions callOptions) @@ -29,6 +40,7 @@ internal sealed class GrpcMxGatewayClientTransport( } } + /// public async Task CloseSessionAsync( CloseSessionRequest request, CallOptions callOptions) @@ -45,6 +57,7 @@ internal sealed class GrpcMxGatewayClientTransport( } } + /// public async Task InvokeAsync( MxCommandRequest request, CallOptions callOptions) @@ -61,6 +74,7 @@ internal sealed class GrpcMxGatewayClientTransport( } } + /// public async IAsyncEnumerable StreamEventsAsync( StreamEventsRequest request, CallOptions callOptions, @@ -94,6 +108,7 @@ internal sealed class GrpcMxGatewayClientTransport( } } + /// IAsyncEnumerable IMxGatewayClientTransport.StreamEventsAsync( StreamEventsRequest request, CallOptions callOptions) diff --git a/clients/dotnet/MxGateway.Client/IGalaxyRepositoryClientTransport.cs b/clients/dotnet/MxGateway.Client/IGalaxyRepositoryClientTransport.cs index de55692..c91ccb5 100644 --- a/clients/dotnet/MxGateway.Client/IGalaxyRepositoryClientTransport.cs +++ b/clients/dotnet/MxGateway.Client/IGalaxyRepositoryClientTransport.cs @@ -3,24 +3,39 @@ using MxGateway.Contracts.Proto.Galaxy; namespace MxGateway.Client; +/// Transport layer for Galaxy Repository gRPC operations. internal interface IGalaxyRepositoryClientTransport { + /// Gets the client options used to configure this transport. MxGatewayClientOptions Options { get; } + /// Gets the underlying gRPC client, or null if not yet initialized. GalaxyRepository.GalaxyRepositoryClient? RawClient { get; } + /// Tests the connection to the Galaxy Repository server. + /// The test connection request. + /// gRPC call options (timeout, cancellation, etc.). Task TestConnectionAsync( TestConnectionRequest request, CallOptions callOptions); + /// Gets the last deploy time from the Galaxy Repository server. + /// The get last deploy time request. + /// gRPC call options (timeout, cancellation, etc.). Task GetLastDeployTimeAsync( GetLastDeployTimeRequest request, CallOptions callOptions); + /// Discovers the object hierarchy in the Galaxy Repository. + /// The discover hierarchy request. + /// gRPC call options (timeout, cancellation, etc.). Task DiscoverHierarchyAsync( DiscoverHierarchyRequest request, CallOptions callOptions); + /// Watches for deployment events from the Galaxy Repository server. + /// The watch deploy events request. + /// gRPC call options (timeout, cancellation, etc.). IAsyncEnumerable WatchDeployEventsAsync( WatchDeployEventsRequest request, CallOptions callOptions); diff --git a/clients/dotnet/MxGateway.Client/IMxGatewayClientTransport.cs b/clients/dotnet/MxGateway.Client/IMxGatewayClientTransport.cs index 77586c6..53a6951 100644 --- a/clients/dotnet/MxGateway.Client/IMxGatewayClientTransport.cs +++ b/clients/dotnet/MxGateway.Client/IMxGatewayClientTransport.cs @@ -5,22 +5,52 @@ namespace MxGateway.Client; internal interface IMxGatewayClientTransport { + /// + /// Gets the client configuration options. + /// MxGatewayClientOptions Options { get; } + /// + /// Gets the underlying gRPC client, if available. + /// MxAccessGateway.MxAccessGatewayClient? RawClient { get; } + /// + /// Opens a new gateway session. + /// + /// Session open request. + /// gRPC call options. + /// The session open reply. Task OpenSessionAsync( OpenSessionRequest request, CallOptions callOptions); + /// + /// Closes an open gateway session. + /// + /// Session close request. + /// gRPC call options. + /// The session close reply. Task CloseSessionAsync( CloseSessionRequest request, CallOptions callOptions); + /// + /// Invokes an MXAccess command on the session. + /// + /// The command request. + /// gRPC call options. + /// The command reply. Task InvokeAsync( MxCommandRequest request, CallOptions callOptions); + /// + /// Streams events from the session. + /// + /// The stream events request. + /// gRPC call options. + /// An async enumerable of events. IAsyncEnumerable StreamEventsAsync( StreamEventsRequest request, CallOptions callOptions); diff --git a/clients/dotnet/MxGateway.Client/MxAccessException.cs b/clients/dotnet/MxGateway.Client/MxAccessException.cs index a2a14ca..38ccc7e 100644 --- a/clients/dotnet/MxGateway.Client/MxAccessException.cs +++ b/clients/dotnet/MxGateway.Client/MxAccessException.cs @@ -2,8 +2,13 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Client; +/// Exception thrown when an MXAccess command fails with a non-zero HResult or failing status. public sealed class MxAccessException : MxGatewayCommandException { + /// Initializes a new instance with the given message, reply, and optional inner exception. + /// The error message describing the MXAccess failure. + /// The MxCommandReply containing the failure details (statuses, HResult, etc.). + /// The underlying exception, if any. public MxAccessException( string message, MxCommandReply reply, @@ -20,5 +25,6 @@ public sealed class MxAccessException : MxGatewayCommandException Reply = reply; } + /// Gets the underlying MxCommandReply containing full failure details. public MxCommandReply Reply { get; } } diff --git a/clients/dotnet/MxGateway.Client/MxCommandReplyExtensions.cs b/clients/dotnet/MxGateway.Client/MxCommandReplyExtensions.cs index ce9f2b8..cd7b37a 100644 --- a/clients/dotnet/MxGateway.Client/MxCommandReplyExtensions.cs +++ b/clients/dotnet/MxGateway.Client/MxCommandReplyExtensions.cs @@ -2,8 +2,11 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Client; +/// Extension methods for checking MxCommandReply success conditions. public static class MxCommandReplyExtensions { + /// Validates that the reply has a successful protocol status (Ok or MxAccessFailure), throwing a gateway exception if not. + /// The command reply to check. public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply) { ArgumentNullException.ThrowIfNull(reply); @@ -19,6 +22,8 @@ public static class MxCommandReplyExtensions throw CreateProtocolException(reply, code); } + /// Validates that the reply indicates MXAccess success (no HResult or status failures), throwing MxAccessException if not. + /// The command reply to check. public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply) { ArgumentNullException.ThrowIfNull(reply); diff --git a/clients/dotnet/MxGateway.Client/MxGatewayAuthenticationException.cs b/clients/dotnet/MxGateway.Client/MxGatewayAuthenticationException.cs index e1164fb..e70b8df 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewayAuthenticationException.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewayAuthenticationException.cs @@ -2,8 +2,17 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Client; +/// Exception thrown when an API key is invalid, expired, or malformed. public sealed class MxGatewayAuthenticationException : MxGatewayException { + /// Initializes a new instance with the given details. + /// The error message describing the authentication failure. + /// The session ID, if available. + /// The correlation ID for tracing, if available. + /// The protocol status details, if available. + /// The HResult code, if available. + /// The MXAccess statuses, if available. + /// The underlying exception, if any. public MxGatewayAuthenticationException( string message, string? sessionId = null, diff --git a/clients/dotnet/MxGateway.Client/MxGatewayAuthorizationException.cs b/clients/dotnet/MxGateway.Client/MxGatewayAuthorizationException.cs index 1c1c67c..2df383b 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewayAuthorizationException.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewayAuthorizationException.cs @@ -2,8 +2,17 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Client; +/// Exception thrown when the API key lacks required scopes for an operation. public sealed class MxGatewayAuthorizationException : MxGatewayException { + /// Initializes a new instance with the given details. + /// The error message describing the authorization failure. + /// The session ID, if available. + /// The correlation ID for tracing, if available. + /// The protocol status details, if available. + /// The HResult code, if available. + /// The MXAccess statuses, if available. + /// The underlying exception, if any. public MxGatewayAuthorizationException( string message, string? sessionId = null, diff --git a/clients/dotnet/MxGateway.Client/MxGatewayClient.cs b/clients/dotnet/MxGateway.Client/MxGatewayClient.cs index 1ca109b..4d29cf4 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewayClient.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewayClient.cs @@ -19,6 +19,11 @@ public sealed class MxGatewayClient : IAsyncDisposable private readonly ResiliencePipeline _safeUnaryRetryPipeline; private bool _disposed; + /// + /// Initializes a new instance of the with given options and transport. + /// + /// Client configuration options. + /// Transport implementation for gateway communication. internal MxGatewayClient( MxGatewayClientOptions options, IMxGatewayClientTransport transport) @@ -46,12 +51,23 @@ public sealed class MxGatewayClient : IAsyncDisposable Options.LoggerFactory?.CreateLogger()); } + /// + /// Gets the client configuration options. + /// public MxGatewayClientOptions Options { get; } + /// + /// Gets the underlying generated gRPC client. + /// public MxAccessGateway.MxAccessGatewayClient RawClient => _transport.RawClient ?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance."); + /// + /// Creates a new gateway client with the given options. + /// + /// Client configuration options. + /// A new gateway client instance. public static MxGatewayClient Create(MxGatewayClientOptions options) { ArgumentNullException.ThrowIfNull(options); @@ -73,6 +89,12 @@ public sealed class MxGatewayClient : IAsyncDisposable new MxAccessGateway.MxAccessGatewayClient(channel))); } + /// + /// Opens a new gateway session. + /// + /// Session open request; defaults to empty request if null. + /// Cancellation token for the operation. + /// A wrapped gateway session. public async Task OpenSessionAsync( OpenSessionRequest? request = null, CancellationToken cancellationToken = default) @@ -85,6 +107,12 @@ public sealed class MxGatewayClient : IAsyncDisposable return new MxGatewaySession(this, reply); } + /// + /// Opens a new gateway session and returns the raw protobuf reply. + /// + /// Session open request. + /// Cancellation token for the operation. + /// The raw gateway session open reply. public Task OpenSessionRawAsync( OpenSessionRequest request, CancellationToken cancellationToken = default) @@ -95,6 +123,12 @@ public sealed class MxGatewayClient : IAsyncDisposable return _transport.OpenSessionAsync(request, CreateCallOptions(cancellationToken)); } + /// + /// Closes an open gateway session. + /// + /// Session close request. + /// Cancellation token for the operation. + /// The session close reply. public Task CloseSessionRawAsync( CloseSessionRequest request, CancellationToken cancellationToken = default) @@ -107,6 +141,12 @@ public sealed class MxGatewayClient : IAsyncDisposable cancellationToken); } + /// + /// Invokes an MXAccess command on the open session. + /// + /// The command request. + /// Cancellation token for the operation. + /// The command reply. public Task InvokeAsync( MxCommandRequest request, CancellationToken cancellationToken = default) @@ -124,6 +164,12 @@ public sealed class MxGatewayClient : IAsyncDisposable return _transport.InvokeAsync(request, CreateCallOptions(cancellationToken)); } + /// + /// Streams events from the gateway session. + /// + /// The stream events request. + /// Cancellation token for the operation. + /// An async enumerable of events. public IAsyncEnumerable StreamEventsAsync( StreamEventsRequest request, CancellationToken cancellationToken = default) @@ -134,6 +180,9 @@ public sealed class MxGatewayClient : IAsyncDisposable return _transport.StreamEventsAsync(request, CreateStreamCallOptions(cancellationToken)); } + /// + /// Disposes the client and releases all resources. + /// public ValueTask DisposeAsync() { if (_disposed) @@ -146,16 +195,32 @@ public sealed class MxGatewayClient : IAsyncDisposable return ValueTask.CompletedTask; } + /// + /// Creates gRPC call options with default timeout and authorization. + /// + /// Cancellation token for the call. + /// Configured call options. internal CallOptions CreateCallOptions(CancellationToken cancellationToken) { return CreateCallOptions(cancellationToken, Options.DefaultCallTimeout); } + /// + /// Creates gRPC call options for streaming with stream timeout and authorization. + /// + /// Cancellation token for the call. + /// Configured call options. internal CallOptions CreateStreamCallOptions(CancellationToken cancellationToken) { return CreateCallOptions(cancellationToken, Options.StreamTimeout); } + /// + /// Creates gRPC call options with specified timeout and authorization. + /// + /// Cancellation token for the call. + /// Optional timeout duration; null means no timeout. + /// Configured call options. internal CallOptions CreateCallOptions( CancellationToken cancellationToken, TimeSpan? timeout) diff --git a/clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs b/clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs index 760fa8d..5cd4551 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewayClientOptions.cs @@ -7,26 +7,62 @@ namespace MxGateway.Client; /// public sealed class MxGatewayClientOptions { + /// + /// Gets the gateway endpoint URI (required). + /// public required Uri Endpoint { get; init; } + /// + /// Gets the API key for gateway authentication (required). + /// public required string ApiKey { get; init; } + /// + /// Gets a value indicating whether to use TLS for the gateway connection. + /// public bool UseTls { get; init; } + /// + /// Gets the path to a CA certificate file for custom certificate validation. + /// public string? CaCertificatePath { get; init; } + /// + /// Gets the server name override for SNI during TLS handshake. + /// public string? ServerNameOverride { get; init; } + /// + /// Gets the timeout for establishing connection to the gateway. + /// public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10); + /// + /// Gets the default timeout for unary gRPC calls. + /// public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30); + /// + /// Gets the optional timeout for streaming gRPC calls. + /// public TimeSpan? StreamTimeout { get; init; } + /// + /// Gets the retry configuration for safe unary calls. + /// public MxGatewayClientRetryOptions Retry { get; init; } = new(); + /// + /// Gets the logger factory for diagnostic logging. + /// public ILoggerFactory? LoggerFactory { get; init; } + /// + /// Validates the client options for consistency and correctness. + /// + /// Endpoint is null. + /// Options are invalid or inconsistent. + /// Timeout values are not greater than zero. public void Validate() { ArgumentNullException.ThrowIfNull(Endpoint); diff --git a/clients/dotnet/MxGateway.Client/MxGatewayClientRetryOptions.cs b/clients/dotnet/MxGateway.Client/MxGatewayClientRetryOptions.cs index 9de5943..6ad212a 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewayClientRetryOptions.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewayClientRetryOptions.cs @@ -1,15 +1,21 @@ namespace MxGateway.Client; +/// Configuration for automatic retry behavior on transient gRPC call failures. public sealed class MxGatewayClientRetryOptions { + /// Gets the maximum number of attempts (initial + retries); default is 2. public int MaxAttempts { get; init; } = 2; + /// Gets the initial delay between retry attempts; default is 200 milliseconds. public TimeSpan Delay { get; init; } = TimeSpan.FromMilliseconds(200); + /// Gets the maximum delay between retry attempts; default is 2 seconds. public TimeSpan MaxDelay { get; init; } = TimeSpan.FromSeconds(2); + /// Gets a value indicating whether to add randomness to retry delays; default is true. public bool UseJitter { get; init; } = true; + /// Validates the retry options and throws if any constraint is violated. public void Validate() { if (MaxAttempts <= 0) diff --git a/clients/dotnet/MxGateway.Client/MxGatewayClientRetryPolicy.cs b/clients/dotnet/MxGateway.Client/MxGatewayClientRetryPolicy.cs index c4cd072..af6e63d 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewayClientRetryPolicy.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewayClientRetryPolicy.cs @@ -6,8 +6,12 @@ using Polly.Retry; namespace MxGateway.Client; +/// Factory and helpers for exponential-backoff retry policies on transient gRPC failures. internal static class MxGatewayClientRetryPolicy { + /// Creates a Polly ResiliencePipeline that retries transient gRPC failures with exponential backoff. + /// Retry configuration (max attempts, delay bounds, jitter). + /// Optional logger for retry diagnostics. public static ResiliencePipeline Create( MxGatewayClientRetryOptions options, ILogger? logger) @@ -36,6 +40,8 @@ internal static class MxGatewayClientRetryPolicy .Build(); } + /// Returns whether a command kind is eligible for automatic retry on transient failures. + /// The command kind to check. public static bool IsRetryableCommand(MxCommandKind kind) { return kind is MxCommandKind.Ping diff --git a/clients/dotnet/MxGateway.Client/MxGatewayCommandException.cs b/clients/dotnet/MxGateway.Client/MxGatewayCommandException.cs index 83baf90..334a619 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewayCommandException.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewayCommandException.cs @@ -2,8 +2,17 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Client; +/// Exception thrown when a gateway command fails due to an unclassified protocol error. public class MxGatewayCommandException : MxGatewayException { + /// Initializes a new instance with the given details. + /// The error message describing the command failure. + /// The session ID, if available. + /// The correlation ID for tracing, if available. + /// The protocol status details, if available. + /// The HResult code, if available. + /// The MXAccess statuses, if available. + /// The underlying exception, if any. public MxGatewayCommandException( string message, string? sessionId = null, diff --git a/clients/dotnet/MxGateway.Client/MxGatewayException.cs b/clients/dotnet/MxGateway.Client/MxGatewayException.cs index eb5b59e..7607317 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewayException.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewayException.cs @@ -2,20 +2,42 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Client; +/// +/// Exception thrown when a gateway RPC call fails or returns an error status. +/// public class MxGatewayException : Exception { + /// + /// Initializes a new instance of the MxGatewayException class with the specified message. + /// + /// Diagnostic message describing the failure. public MxGatewayException(string message) : base(message) { Statuses = []; } + /// + /// Initializes a new instance of the MxGatewayException class with the specified message and inner exception. + /// + /// Diagnostic message describing the failure. + /// Underlying exception that caused this failure. public MxGatewayException(string message, Exception? innerException) : base(message, innerException) { Statuses = []; } + /// + /// Initializes a new instance of the MxGatewayException class with full diagnostic information. + /// + /// Diagnostic message describing the failure. + /// Session ID associated with the exception, if available. + /// Correlation ID associated with the exception, if available. + /// Protocol-level status returned by the gateway, if available. + /// HRESULT code returned by the worker or MXAccess, if available. + /// List of MXAccess status codes returned by the operation. + /// Underlying exception that caused this failure. public MxGatewayException( string message, string? sessionId, @@ -33,13 +55,28 @@ public class MxGatewayException : Exception Statuses = statuses; } + /// + /// Gets the session ID associated with the exception, if available. + /// public string? SessionId { get; } + /// + /// Gets the correlation ID associated with the exception, if available. + /// public string? CorrelationId { get; } + /// + /// Gets the protocol-level status returned by the gateway, if available. + /// public ProtocolStatus? ProtocolStatus { get; } + /// + /// Gets the HRESULT code returned by the worker or MXAccess, if available. + /// public int? HResultCode { get; } + /// + /// Gets the list of MXAccess status codes returned by the operation. + /// public IReadOnlyList Statuses { get; } } diff --git a/clients/dotnet/MxGateway.Client/MxGatewaySession.cs b/clients/dotnet/MxGateway.Client/MxGatewaySession.cs index 4730c50..62a9a1a 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewaySession.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewaySession.cs @@ -11,6 +11,11 @@ public sealed class MxGatewaySession : IAsyncDisposable private readonly SemaphoreSlim _closeLock = new(1, 1); private CloseSessionReply? _closeReply; + /// + /// Initializes a new session backed by the given MXAccess gateway client. + /// + /// The gateway client used for commands and events. + /// The server's session creation response. internal MxGatewaySession( MxGatewayClient client, OpenSessionReply openSessionReply) @@ -19,10 +24,21 @@ public sealed class MxGatewaySession : IAsyncDisposable OpenSessionReply = openSessionReply ?? throw new ArgumentNullException(nameof(openSessionReply)); } + /// + /// The session ID assigned by the gateway. + /// public string SessionId => OpenSessionReply.SessionId; + /// + /// The server's session creation response containing metadata. + /// public OpenSessionReply OpenSessionReply { get; } + /// + /// Closes the session on the gateway. Idempotent. + /// + /// Cancellation token. + /// The server's close-session reply. public async Task CloseAsync(CancellationToken cancellationToken = default) { if (_closeReply is not null) @@ -50,6 +66,12 @@ public sealed class MxGatewaySession : IAsyncDisposable } } + /// + /// Registers a client with the MXAccess session, returning a ServerHandle. + /// + /// Name to register. + /// Cancellation token. + /// The server handle assigned to the registered client. public async Task RegisterAsync( string clientName, CancellationToken cancellationToken = default) @@ -60,6 +82,12 @@ public sealed class MxGatewaySession : IAsyncDisposable return reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value; } + /// + /// Registers a client with the MXAccess session without error checking. + /// + /// Name to register. + /// Cancellation token. + /// The raw server reply. public Task RegisterRawAsync( string clientName, CancellationToken cancellationToken = default) @@ -75,6 +103,13 @@ public sealed class MxGatewaySession : IAsyncDisposable cancellationToken); } + /// + /// Adds an item to the MXAccess session, returning an ItemHandle. + /// + /// The ServerHandle from register. + /// The item tag address. + /// Cancellation token. + /// The item handle assigned to the new item. public async Task AddItemAsync( int serverHandle, string itemDefinition, @@ -89,6 +124,13 @@ public sealed class MxGatewaySession : IAsyncDisposable return reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value; } + /// + /// Adds an item to the MXAccess session without error checking. + /// + /// The ServerHandle from register. + /// The item tag address. + /// Cancellation token. + /// The raw server reply. public Task AddItemRawAsync( int serverHandle, string itemDefinition, @@ -109,6 +151,14 @@ public sealed class MxGatewaySession : IAsyncDisposable cancellationToken); } + /// + /// Adds an item with context to the MXAccess session, returning an ItemHandle. + /// + /// The ServerHandle from register. + /// The item tag address. + /// Additional context for the item. + /// Cancellation token. + /// The item handle assigned to the new item. public async Task AddItem2Async( int serverHandle, string itemDefinition, @@ -125,6 +175,14 @@ public sealed class MxGatewaySession : IAsyncDisposable return reply.AddItem2?.ItemHandle ?? reply.ReturnValue.Int32Value; } + /// + /// Adds an item with context to the MXAccess session without error checking. + /// + /// The ServerHandle from register. + /// The item tag address. + /// Additional context for the item. + /// Cancellation token. + /// The raw server reply. public Task AddItem2RawAsync( int serverHandle, string itemDefinition, @@ -147,6 +205,12 @@ public sealed class MxGatewaySession : IAsyncDisposable cancellationToken); } + /// + /// Subscribes to events for an item (advises in MXAccess terminology). + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// Cancellation token. public async Task AdviseAsync( int serverHandle, int itemHandle, @@ -157,6 +221,13 @@ public sealed class MxGatewaySession : IAsyncDisposable reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); } + /// + /// Subscribes to events for an item without error checking. + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// Cancellation token. + /// The raw server reply. public Task AdviseRawAsync( int serverHandle, int itemHandle, @@ -175,6 +246,12 @@ public sealed class MxGatewaySession : IAsyncDisposable cancellationToken); } + /// + /// Unsubscribes from events for an item (unadvises in MXAccess terminology). + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// Cancellation token. public async Task UnAdviseAsync( int serverHandle, int itemHandle, @@ -185,6 +262,13 @@ public sealed class MxGatewaySession : IAsyncDisposable reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); } + /// + /// Unsubscribes from events for an item without error checking. + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// Cancellation token. + /// The raw server reply. public Task UnAdviseRawAsync( int serverHandle, int itemHandle, @@ -203,6 +287,12 @@ public sealed class MxGatewaySession : IAsyncDisposable cancellationToken); } + /// + /// Removes an item from the MXAccess session. + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// Cancellation token. public async Task RemoveItemAsync( int serverHandle, int itemHandle, @@ -213,6 +303,13 @@ public sealed class MxGatewaySession : IAsyncDisposable reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); } + /// + /// Removes an item from the MXAccess session without error checking. + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// Cancellation token. + /// The raw server reply. public Task RemoveItemRawAsync( int serverHandle, int itemHandle, @@ -231,6 +328,13 @@ public sealed class MxGatewaySession : IAsyncDisposable cancellationToken); } + /// + /// Adds multiple items to the MXAccess session in a single command. + /// + /// The ServerHandle from register. + /// The item tag addresses to add. + /// Cancellation token. + /// Per-item subscription results. public async Task> AddItemBulkAsync( int serverHandle, IReadOnlyList tagAddresses, @@ -253,6 +357,13 @@ public sealed class MxGatewaySession : IAsyncDisposable return reply.AddItemBulk?.Results.ToArray() ?? []; } + /// + /// Advises multiple items in a single command. + /// + /// The ServerHandle from register. + /// The ItemHandles to advise. + /// Cancellation token. + /// Per-item subscription results. public async Task> AdviseItemBulkAsync( int serverHandle, IReadOnlyList itemHandles, @@ -275,6 +386,13 @@ public sealed class MxGatewaySession : IAsyncDisposable return reply.AdviseItemBulk?.Results.ToArray() ?? []; } + /// + /// Removes multiple items in a single command. + /// + /// The ServerHandle from register. + /// The ItemHandles to remove. + /// Cancellation token. + /// Per-item subscription results. public async Task> RemoveItemBulkAsync( int serverHandle, IReadOnlyList itemHandles, @@ -297,6 +415,13 @@ public sealed class MxGatewaySession : IAsyncDisposable return reply.RemoveItemBulk?.Results.ToArray() ?? []; } + /// + /// Unadvises multiple items in a single command. + /// + /// The ServerHandle from register. + /// The ItemHandles to unadvise. + /// Cancellation token. + /// Per-item subscription results. public async Task> UnAdviseItemBulkAsync( int serverHandle, IReadOnlyList itemHandles, @@ -319,6 +444,13 @@ public sealed class MxGatewaySession : IAsyncDisposable return reply.UnAdviseItemBulk?.Results.ToArray() ?? []; } + /// + /// Adds and advises multiple items in a single command. + /// + /// The ServerHandle from register. + /// The item tag addresses to add and advise. + /// Cancellation token. + /// Per-item subscription results. public async Task> SubscribeBulkAsync( int serverHandle, IReadOnlyList tagAddresses, @@ -341,6 +473,13 @@ public sealed class MxGatewaySession : IAsyncDisposable return reply.SubscribeBulk?.Results.ToArray() ?? []; } + /// + /// Unadvises and removes multiple items in a single command. + /// + /// The ServerHandle from register. + /// The ItemHandles to unsubscribe. + /// Cancellation token. + /// Per-item subscription results. public async Task> UnsubscribeBulkAsync( int serverHandle, IReadOnlyList itemHandles, @@ -363,6 +502,14 @@ public sealed class MxGatewaySession : IAsyncDisposable return reply.UnsubscribeBulk?.Results.ToArray() ?? []; } + /// + /// Writes a value to an item on the MXAccess server. + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// The value to write. + /// User ID context for the write. + /// Cancellation token. public async Task WriteAsync( int serverHandle, int itemHandle, @@ -375,6 +522,15 @@ public sealed class MxGatewaySession : IAsyncDisposable reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); } + /// + /// Writes a value to an item on the MXAccess server without error checking. + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// The value to write. + /// User ID context for the write. + /// Cancellation token. + /// The raw server reply. public Task WriteRawAsync( int serverHandle, int itemHandle, @@ -399,6 +555,15 @@ public sealed class MxGatewaySession : IAsyncDisposable cancellationToken); } + /// + /// Writes a value and timestamp to an item on the MXAccess server. + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// The value to write. + /// The timestamp to write with the value. + /// User ID context for the write. + /// Cancellation token. public async Task Write2Async( int serverHandle, int itemHandle, @@ -418,6 +583,16 @@ public sealed class MxGatewaySession : IAsyncDisposable reply.EnsureProtocolSuccess().EnsureMxAccessSuccess(); } + /// + /// Writes a value and timestamp to an item on the MXAccess server without error checking. + /// + /// The ServerHandle from register. + /// The ItemHandle from add-item. + /// The value to write. + /// The timestamp to write with the value. + /// User ID context for the write. + /// Cancellation token. + /// The raw server reply. public Task Write2RawAsync( int serverHandle, int itemHandle, @@ -445,6 +620,12 @@ public sealed class MxGatewaySession : IAsyncDisposable cancellationToken); } + /// + /// Invokes an MXAccess command on this session. + /// + /// The command request. + /// Cancellation token. + /// The raw server reply. public Task InvokeAsync( MxCommandRequest request, CancellationToken cancellationToken = default) @@ -453,6 +634,12 @@ public sealed class MxGatewaySession : IAsyncDisposable return _client.InvokeAsync(request, cancellationToken); } + /// + /// Streams events from the worker for this session, optionally starting after a given sequence number. + /// + /// The sequence number to stream from. Defaults to 0. + /// Cancellation token. + /// An async enumerable of events. public IAsyncEnumerable StreamEventsAsync( ulong afterWorkerSequence = 0, CancellationToken cancellationToken = default) @@ -466,6 +653,9 @@ public sealed class MxGatewaySession : IAsyncDisposable cancellationToken); } + /// + /// Closes the session and releases resources. + /// public async ValueTask DisposeAsync() { await CloseAsync().ConfigureAwait(false); diff --git a/clients/dotnet/MxGateway.Client/MxGatewaySessionException.cs b/clients/dotnet/MxGateway.Client/MxGatewaySessionException.cs index f7ae5db..b7d971a 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewaySessionException.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewaySessionException.cs @@ -2,8 +2,17 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Client; +/// Exception thrown when a session is not found, not ready, or invalid. public sealed class MxGatewaySessionException : MxGatewayException { + /// Initializes a new instance with the given details. + /// The error message describing the session failure. + /// The session ID, if available. + /// The correlation ID for tracing, if available. + /// The protocol status details, if available. + /// The HResult code, if available. + /// The MXAccess statuses, if available. + /// The underlying exception, if any. public MxGatewaySessionException( string message, string? sessionId = null, diff --git a/clients/dotnet/MxGateway.Client/MxGatewayWorkerException.cs b/clients/dotnet/MxGateway.Client/MxGatewayWorkerException.cs index 794a10b..2731c97 100644 --- a/clients/dotnet/MxGateway.Client/MxGatewayWorkerException.cs +++ b/clients/dotnet/MxGateway.Client/MxGatewayWorkerException.cs @@ -2,8 +2,17 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Client; +/// Exception thrown when the worker process is unavailable or fails to process a command. public sealed class MxGatewayWorkerException : MxGatewayException { + /// Initializes a new instance with the given details. + /// The error message describing the worker failure. + /// The session ID, if available. + /// The correlation ID for tracing, if available. + /// The protocol status details, if available. + /// The HResult code, if available. + /// The MXAccess statuses, if available. + /// The underlying exception, if any. public MxGatewayWorkerException( string message, string? sessionId = null, diff --git a/clients/dotnet/MxGateway.Client/MxStatusProxyExtensions.cs b/clients/dotnet/MxGateway.Client/MxStatusProxyExtensions.cs index a3086b8..07711ee 100644 --- a/clients/dotnet/MxGateway.Client/MxStatusProxyExtensions.cs +++ b/clients/dotnet/MxGateway.Client/MxStatusProxyExtensions.cs @@ -2,8 +2,11 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Client; +/// Extension methods for MxStatusProxy values. public static class MxStatusProxyExtensions { + /// Returns whether the status indicates success (success flag set and category is Ok). + /// The status to check. public static bool IsSuccess(this MxStatusProxy status) { ArgumentNullException.ThrowIfNull(status); @@ -12,6 +15,8 @@ public static class MxStatusProxyExtensions && status.Category is MxStatusCategory.Ok; } + /// Returns a formatted summary of the status for diagnostic output. + /// The status to summarize. public static string ToDiagnosticSummary(this MxStatusProxy status) { ArgumentNullException.ThrowIfNull(status); diff --git a/clients/dotnet/MxGateway.Client/MxValueExtensions.cs b/clients/dotnet/MxGateway.Client/MxValueExtensions.cs index 5b75d2c..72e6020 100644 --- a/clients/dotnet/MxGateway.Client/MxValueExtensions.cs +++ b/clients/dotnet/MxGateway.Client/MxValueExtensions.cs @@ -10,6 +10,10 @@ namespace MxGateway.Client; /// public static class MxValueExtensions { + /// + /// Converts a boolean value to an MxValue with MxDataType.Boolean. + /// + /// Scalar boolean value to wrap. public static MxValue ToMxValue(this bool value) { return new MxValue @@ -20,6 +24,10 @@ public static class MxValueExtensions }; } + /// + /// Converts a 32-bit integer value to an MxValue with MxDataType.Integer. + /// + /// 32-bit integer value to wrap. public static MxValue ToMxValue(this int value) { return new MxValue @@ -30,6 +38,10 @@ public static class MxValueExtensions }; } + /// + /// Converts a 64-bit integer value to an MxValue with MxDataType.Integer. + /// + /// 64-bit integer value to wrap. public static MxValue ToMxValue(this long value) { return new MxValue @@ -40,6 +52,10 @@ public static class MxValueExtensions }; } + /// + /// Converts a single-precision floating-point value to an MxValue with MxDataType.Float. + /// + /// Single-precision floating-point value to wrap. public static MxValue ToMxValue(this float value) { return new MxValue @@ -50,6 +66,10 @@ public static class MxValueExtensions }; } + /// + /// Converts a double-precision floating-point value to an MxValue with MxDataType.Double. + /// + /// Double-precision floating-point value to wrap. public static MxValue ToMxValue(this double value) { return new MxValue @@ -60,6 +80,10 @@ public static class MxValueExtensions }; } + /// + /// Converts a string value to an MxValue with MxDataType.String. + /// + /// String value to wrap. public static MxValue ToMxValue(this string value) { ArgumentNullException.ThrowIfNull(value); @@ -72,6 +96,10 @@ public static class MxValueExtensions }; } + /// + /// Converts a DateTimeOffset value to an MxValue with MxDataType.Time. + /// + /// DateTimeOffset value to wrap. public static MxValue ToMxValue(this DateTimeOffset value) { return new MxValue @@ -82,6 +110,10 @@ public static class MxValueExtensions }; } + /// + /// Converts a DateTime value to an MxValue with MxDataType.Time. + /// + /// DateTime value to wrap. public static MxValue ToMxValue(this DateTime value) { return new DateTimeOffset( @@ -91,6 +123,10 @@ public static class MxValueExtensions .ToMxValue(); } + /// + /// Converts a boolean array to an MxValue with MxDataType.Boolean. + /// + /// Array of boolean values to wrap. public static MxValue ToMxValue(this IReadOnlyList values) { ArgumentNullException.ThrowIfNull(values); @@ -105,6 +141,10 @@ public static class MxValueExtensions }); } + /// + /// Converts a 32-bit integer array to an MxValue with MxDataType.Integer. + /// + /// Array of 32-bit integer values to wrap. public static MxValue ToMxValue(this IReadOnlyList values) { ArgumentNullException.ThrowIfNull(values); @@ -119,6 +159,10 @@ public static class MxValueExtensions }); } + /// + /// Converts a 64-bit integer array to an MxValue with MxDataType.Integer. + /// + /// Array of 64-bit integer values to wrap. public static MxValue ToMxValue(this IReadOnlyList values) { ArgumentNullException.ThrowIfNull(values); @@ -133,6 +177,10 @@ public static class MxValueExtensions }); } + /// + /// Converts a single-precision floating-point array to an MxValue with MxDataType.Float. + /// + /// Array of single-precision floating-point values to wrap. public static MxValue ToMxValue(this IReadOnlyList values) { ArgumentNullException.ThrowIfNull(values); @@ -147,6 +195,10 @@ public static class MxValueExtensions }); } + /// + /// Converts a double-precision floating-point array to an MxValue with MxDataType.Double. + /// + /// Array of double-precision floating-point values to wrap. public static MxValue ToMxValue(this IReadOnlyList values) { ArgumentNullException.ThrowIfNull(values); @@ -161,6 +213,10 @@ public static class MxValueExtensions }); } + /// + /// Converts a string array to an MxValue with MxDataType.String. + /// + /// Array of string values to wrap. public static MxValue ToMxValue(this IReadOnlyList values) { ArgumentNullException.ThrowIfNull(values); @@ -175,6 +231,10 @@ public static class MxValueExtensions }); } + /// + /// Converts a DateTimeOffset array to an MxValue with MxDataType.Time. + /// + /// Array of DateTimeOffset values to wrap. public static MxValue ToMxValue(this IReadOnlyList values) { ArgumentNullException.ThrowIfNull(values); @@ -189,6 +249,10 @@ public static class MxValueExtensions }); } + /// + /// Gets the projection kind (field name) of the given MxValue's current oneof value. + /// + /// The MxValue whose oneof projection kind is returned. public static string GetProjectionKind(this MxValue value) { ArgumentNullException.ThrowIfNull(value); @@ -208,6 +272,10 @@ public static class MxValueExtensions }; } + /// + /// Converts an MxValue to a CLR object; returns the boxed value or null for null MxValues. + /// + /// The MxValue to convert. public static object? ToClrValue(this MxValue value) { ArgumentNullException.ThrowIfNull(value); @@ -227,6 +295,10 @@ public static class MxValueExtensions }; } + /// + /// Converts an MxArray to a CLR array; returns null if the array does not have a known element type. + /// + /// The MxArray to convert. public static object? ToClrArrayValue(this MxArray array) { ArgumentNullException.ThrowIfNull(array); @@ -249,6 +321,13 @@ public static class MxValueExtensions }; } + /// + /// Creates an MxValue with MxDataType.Unknown from raw byte data, variant type, and diagnostic info. + /// + /// Raw byte data representing the value. + /// Variant type string (e.g., "VT_BSTR"). + /// Diagnostic string describing the raw value. + /// Optional MXAccess data type override. public static MxValue ToRawMxValue( byte[] value, string variantType, diff --git a/src/MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs b/src/MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs index 34ae67d..731d252 100644 --- a/src/MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs +++ b/src/MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs @@ -4,6 +4,7 @@ namespace MxGateway.IntegrationTests.Galaxy; public sealed class GalaxyRepositoryLiveTests { + /// Verifies that the Galaxy Repository can establish a live connection to the ZB database. [LiveGalaxyRepositoryFact] [Trait("Category", "LiveGalaxy")] public async Task TestConnection_AgainstZb_Succeeds() @@ -15,6 +16,7 @@ public sealed class GalaxyRepositoryLiveTests Assert.True(ok, "TestConnectionAsync should return true against the ZB database."); } + /// Verifies that the last deploy time can be retrieved from the ZB database. [LiveGalaxyRepositoryFact] [Trait("Category", "LiveGalaxy")] public async Task GetLastDeployTime_AgainstZb_ReturnsTimestamp() @@ -26,6 +28,7 @@ public sealed class GalaxyRepositoryLiveTests Assert.NotNull(lastDeploy); } + /// Verifies that the hierarchy can be retrieved from the ZB database. [LiveGalaxyRepositoryFact] [Trait("Category", "LiveGalaxy")] public async Task GetHierarchy_AgainstZb_ReturnsObjects() @@ -43,6 +46,7 @@ public sealed class GalaxyRepositoryLiveTests }); } + /// Verifies that object attributes can be retrieved from the ZB database. [LiveGalaxyRepositoryFact] [Trait("Category", "LiveGalaxy")] public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute() diff --git a/src/MxGateway.IntegrationTests/Galaxy/LiveGalaxyRepositoryFactAttribute.cs b/src/MxGateway.IntegrationTests/Galaxy/LiveGalaxyRepositoryFactAttribute.cs index 585b9d8..19896c1 100644 --- a/src/MxGateway.IntegrationTests/Galaxy/LiveGalaxyRepositoryFactAttribute.cs +++ b/src/MxGateway.IntegrationTests/Galaxy/LiveGalaxyRepositoryFactAttribute.cs @@ -1,10 +1,14 @@ namespace MxGateway.IntegrationTests.Galaxy; +/// Fact attribute that skips tests unless live Galaxy Repository tests are explicitly enabled. public sealed class LiveGalaxyRepositoryFactAttribute : FactAttribute { + /// Environment variable name to enable live Galaxy Repository tests. public const string EnableVariableName = "MXGATEWAY_RUN_LIVE_GALAXY_TESTS"; + /// Environment variable name for the Galaxy Repository connection string. public const string ConnectionStringVariableName = "MXGATEWAY_LIVE_GALAXY_CONN"; + /// Initializes a new instance of the LiveGalaxyRepositoryFactAttribute class. public LiveGalaxyRepositoryFactAttribute() { if (!Enabled) @@ -13,12 +17,14 @@ public sealed class LiveGalaxyRepositoryFactAttribute : FactAttribute } } + /// Gets a value indicating whether live Galaxy Repository tests are enabled. public static bool Enabled => string.Equals( Environment.GetEnvironmentVariable(EnableVariableName), "1", StringComparison.Ordinal); + /// Gets the Galaxy Repository connection string from environment or default. public static string ConnectionString => Environment.GetEnvironmentVariable(ConnectionStringVariableName) ?? "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;"; diff --git a/src/MxGateway.IntegrationTests/IntegrationTestEnvironment.cs b/src/MxGateway.IntegrationTests/IntegrationTestEnvironment.cs index ec7768a..0f2c470 100644 --- a/src/MxGateway.IntegrationTests/IntegrationTestEnvironment.cs +++ b/src/MxGateway.IntegrationTests/IntegrationTestEnvironment.cs @@ -8,27 +8,33 @@ public static class IntegrationTestEnvironment public const string LiveMxAccessClientNameVariableName = "MXGATEWAY_LIVE_MXACCESS_CLIENT_NAME"; public const string LiveMxAccessEventTimeoutSecondsVariableName = "MXGATEWAY_LIVE_MXACCESS_EVENT_TIMEOUT_SECONDS"; + /// Gets whether live MXAccess tests are enabled. public static bool LiveMxAccessTestsEnabled => string.Equals( Environment.GetEnvironmentVariable(LiveMxAccessVariableName), "1", StringComparison.Ordinal); + /// Gets the MXAccess item name for live tests. public static string LiveMxAccessItem => GetOptionalEnvironmentVariable( LiveMxAccessItemVariableName, "TestChildObject.TestInt"); + /// Gets the client name for live tests. public static string LiveMxAccessClientName => GetOptionalEnvironmentVariable( LiveMxAccessClientNameVariableName, "MxGateway.IntegrationTests"); + /// Gets the timeout for waiting on events in live tests. public static TimeSpan LiveMxAccessEventTimeout => TimeSpan.FromSeconds(GetPositiveIntegerEnvironmentVariable( LiveMxAccessEventTimeoutSecondsVariableName, defaultValue: 15)); + /// Resolves the path to the worker executable for live tests. + /// Path to MxGateway.Worker.exe. public static string ResolveLiveMxAccessWorkerExecutablePath() { string? configuredPath = Environment.GetEnvironmentVariable(LiveMxAccessWorkerExecutableVariableName); @@ -74,6 +80,9 @@ public static class IntegrationTestEnvironment return defaultValue; } + /// Resolves the root directory of the repository by searching for .git and src directories. + /// Starting directory to search from. + /// The repository root path, or the start directory if not found. internal static string ResolveRepositoryRoot(string startDirectory) { DirectoryInfo? directory = new(startDirectory); diff --git a/src/MxGateway.IntegrationTests/IntegrationTestEnvironmentTests.cs b/src/MxGateway.IntegrationTests/IntegrationTestEnvironmentTests.cs index 512f283..871bf57 100644 --- a/src/MxGateway.IntegrationTests/IntegrationTestEnvironmentTests.cs +++ b/src/MxGateway.IntegrationTests/IntegrationTestEnvironmentTests.cs @@ -2,6 +2,7 @@ namespace MxGateway.IntegrationTests; public sealed class IntegrationTestEnvironmentTests { + /// Verifies that live MXAccess tests use correct environment variable name. [Fact] public void LiveMxAccessTests_AreOptInByEnvironmentVariable() { @@ -10,6 +11,7 @@ public sealed class IntegrationTestEnvironmentTests IntegrationTestEnvironment.LiveMxAccessVariableName); } + /// Verifies that worker executable uses correct environment variable name. [Fact] public void LiveMxAccessWorkerExecutable_UsesDocumentedEnvironmentVariable() { @@ -18,6 +20,7 @@ public sealed class IntegrationTestEnvironmentTests IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName); } + /// Verifies that repository root resolution accepts git worktree files. [Fact] public void ResolveRepositoryRoot_AcceptsGitWorktreeFile() { diff --git a/src/MxGateway.IntegrationTests/LiveMxAccessFactAttribute.cs b/src/MxGateway.IntegrationTests/LiveMxAccessFactAttribute.cs index b89cf9c..85263c8 100644 --- a/src/MxGateway.IntegrationTests/LiveMxAccessFactAttribute.cs +++ b/src/MxGateway.IntegrationTests/LiveMxAccessFactAttribute.cs @@ -1,7 +1,9 @@ namespace MxGateway.IntegrationTests; +/// Marks an xUnit test as requiring installed MXAccess COM and live provider state. public sealed class LiveMxAccessFactAttribute : FactAttribute { + /// Initializes the attribute, skipping the test unless the integration test environment variable is set. public LiveMxAccessFactAttribute() { if (!IntegrationTestEnvironment.LiveMxAccessTestsEnabled) diff --git a/src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs b/src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs index b773604..951fc50 100644 --- a/src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs +++ b/src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs @@ -21,6 +21,9 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(15); private static readonly TimeSpan StreamShutdownTimeout = TimeSpan.FromSeconds(10); + /// + /// Verifies that a gateway session can register, add item, advise, and stream events from live MXAccess. + /// [LiveMxAccessFact] [Trait("Category", "LiveMxAccess")] public async Task GatewaySession_WithLiveWorker_RegistersAdvisesStreamsDataAndCloses() @@ -208,12 +211,21 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) $"Event value_type={dataChange.Value?.DataType} raw_status={dataChange.RawStatus}"); } + /// + /// Test fixture that assembles the gateway service with a worker process factory for live MXAccess testing. + /// private sealed class GatewayServiceFixture : IAsyncDisposable { private readonly GatewayMetrics _metrics = new(); private readonly SessionRegistry _registry = new(); private readonly ILoggerFactory _loggerFactory; + /// + /// Initializes the fixture with worker executable path, factory, and test output helper. + /// + /// Path to the worker process executable. + /// Factory for creating worker processes. + /// Test output helper for logging. public GatewayServiceFixture( string workerExecutablePath, IWorkerProcessFactory processFactory, @@ -255,8 +267,14 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) _loggerFactory.CreateLogger()); } + /// + /// The assembled gateway service instance. + /// public MxAccessGatewayService Service { get; } + /// + /// Disposes the fixture resources and closes all sessions. + /// public async ValueTask DisposeAsync() { foreach (GatewaySession session in _registry.Snapshot()) @@ -295,12 +313,18 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) } } + /// + /// Gathers messages written to a server stream for test inspection. + /// private sealed class RecordingServerStreamWriter : IServerStreamWriter { private readonly object syncRoot = new(); private readonly TaskCompletionSource firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly List messages = []; + /// + /// All messages that have been written to the stream. + /// public IReadOnlyList Messages { get @@ -312,8 +336,15 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) } } + /// + /// Inherited write options. + /// public WriteOptions? WriteOptions { get; set; } + /// + /// Records the message and completes the first-message task. + /// + /// The message to write. public Task WriteAsync(T message) { lock (syncRoot) @@ -325,12 +356,20 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) return Task.CompletedTask; } + /// + /// Waits for the first message up to the specified timeout. + /// + /// The maximum time to wait. + /// The first message written to the stream. public async Task WaitForFirstMessageAsync(TimeSpan timeout) { return await firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false); } } + /// + /// Mock server call context for testing gRPC calls. + /// private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext { private readonly Metadata requestHeaders = []; @@ -339,43 +378,56 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) 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) { @@ -383,10 +435,14 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) } } + /// + /// Factory that launches worker processes and records their outputs for testing. + /// private sealed class TestWorkerProcessFactory(ITestOutputHelper output) : IWorkerProcessFactory { private readonly ConcurrentBag processes = []; + /// public IWorkerProcess Start(ProcessStartInfo startInfo) { startInfo.RedirectStandardError = true; @@ -418,6 +474,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) return workerProcess; } + /// public async Task WaitForProcessesAsync(TimeSpan timeout) { foreach (TestWorkerProcess process in processes) @@ -445,57 +502,77 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) } } + /// + /// Adapter wrapping a System.Diagnostics.Process as IWorkerProcess for testing. + /// private sealed class TestWorkerProcess(Process process) : IWorkerProcess { + /// public int Id => process.Id; + /// public bool HasExited => process.HasExited; + /// public int? ExitCode => process.HasExited ? process.ExitCode : null; + /// public async ValueTask WaitForExitAsync(CancellationToken cancellationToken) { await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); } + /// public void Kill(bool entireProcessTree) { process.Kill(entireProcessTree); } + /// public void Dispose() { process.Dispose(); } } + /// + /// Logger provider that writes all output to the test output helper. + /// private sealed class TestOutputLoggerProvider(ITestOutputHelper output) : ILoggerProvider { + /// public ILogger CreateLogger(string categoryName) { return new TestOutputLogger(output, categoryName); } + /// public void Dispose() { } } + /// + /// Logger that writes messages to the test output helper. + /// private sealed class TestOutputLogger( ITestOutputHelper output, string categoryName) : ILogger { + /// public IDisposable? BeginScope(TState state) where TState : notnull { return null; } + /// public bool IsEnabled(LogLevel logLevel) { return logLevel >= LogLevel.Information; } + /// public void Log( LogLevel logLevel, EventId eventId, diff --git a/src/MxGateway.Server/Configuration/AuthenticationOptions.cs b/src/MxGateway.Server/Configuration/AuthenticationOptions.cs index b3a9962..9e3aac7 100644 --- a/src/MxGateway.Server/Configuration/AuthenticationOptions.cs +++ b/src/MxGateway.Server/Configuration/AuthenticationOptions.cs @@ -2,11 +2,15 @@ namespace MxGateway.Server.Configuration; public sealed class AuthenticationOptions { + /// Gets the authentication mode. public AuthenticationMode Mode { get; init; } = AuthenticationMode.ApiKey; + /// Gets the SQLite database path for authentication credentials. public string SqlitePath { get; init; } = @"C:\ProgramData\MxGateway\gateway-auth.db"; + /// Gets the secret manager name for API key pepper. public string PepperSecretName { get; init; } = "MxGateway:ApiKeyPepper"; + /// Gets whether database migrations should run on startup. public bool RunMigrationsOnStartup { get; init; } = true; } diff --git a/src/MxGateway.Server/Configuration/DashboardOptions.cs b/src/MxGateway.Server/Configuration/DashboardOptions.cs index 668f965..717d24a 100644 --- a/src/MxGateway.Server/Configuration/DashboardOptions.cs +++ b/src/MxGateway.Server/Configuration/DashboardOptions.cs @@ -2,19 +2,27 @@ namespace MxGateway.Server.Configuration; public sealed class DashboardOptions { + /// Gets whether the dashboard is enabled. public bool Enabled { get; init; } = true; + /// Gets the dashboard URL path base. public string PathBase { get; init; } = "/dashboard"; + /// Gets whether dashboard access requires admin scope. public bool RequireAdminScope { get; init; } = true; + /// Gets whether anonymous localhost access to dashboard is allowed. public bool AllowAnonymousLocalhost { get; init; } = true; + /// Gets the dashboard snapshot update interval in milliseconds. public int SnapshotIntervalMilliseconds { get; init; } = 1_000; + /// Gets the maximum number of recent faults to display. public int RecentFaultLimit { get; init; } = 100; + /// Gets the maximum number of recent sessions to display. public int RecentSessionLimit { get; init; } = 200; + /// Gets whether to show full tag values in the dashboard. public bool ShowTagValues { get; init; } } diff --git a/src/MxGateway.Server/Configuration/EventOptions.cs b/src/MxGateway.Server/Configuration/EventOptions.cs index b93323e..a3831cf 100644 --- a/src/MxGateway.Server/Configuration/EventOptions.cs +++ b/src/MxGateway.Server/Configuration/EventOptions.cs @@ -2,7 +2,13 @@ namespace MxGateway.Server.Configuration; public sealed class EventOptions { + /// + /// Gets the event queue capacity. + /// public int QueueCapacity { get; init; } = 10_000; + /// + /// Gets the backpressure policy for event queue overflow. + /// public EventBackpressurePolicy BackpressurePolicy { get; init; } = EventBackpressurePolicy.FailFast; } diff --git a/src/MxGateway.Server/Configuration/GatewayConfigurationProvider.cs b/src/MxGateway.Server/Configuration/GatewayConfigurationProvider.cs index 69c97bf..868954c 100644 --- a/src/MxGateway.Server/Configuration/GatewayConfigurationProvider.cs +++ b/src/MxGateway.Server/Configuration/GatewayConfigurationProvider.cs @@ -2,10 +2,13 @@ using Microsoft.Extensions.Options; namespace MxGateway.Server.Configuration; +/// Provides the effective gateway configuration with sensitive values redacted. public sealed class GatewayConfigurationProvider(IOptions options) : IGatewayConfigurationProvider { + /// Marker string for redacted sensitive configuration values. public const string RedactedValue = "[redacted]"; + /// public EffectiveGatewayConfiguration GetEffectiveConfiguration() { GatewayOptions value = options.Value; diff --git a/src/MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs b/src/MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs index 9c23e61..b514bf0 100644 --- a/src/MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs +++ b/src/MxGateway.Server/Configuration/GatewayConfigurationServiceCollectionExtensions.cs @@ -4,6 +4,9 @@ namespace MxGateway.Server.Configuration; public static class GatewayConfigurationServiceCollectionExtensions { + /// Registers gateway configuration services in the dependency injection container. + /// The service collection. + /// The service collection for chaining. public static IServiceCollection AddGatewayConfiguration(this IServiceCollection services) { services diff --git a/src/MxGateway.Server/Configuration/GatewayOptions.cs b/src/MxGateway.Server/Configuration/GatewayOptions.cs index b1b7585..5c735b0 100644 --- a/src/MxGateway.Server/Configuration/GatewayOptions.cs +++ b/src/MxGateway.Server/Configuration/GatewayOptions.cs @@ -4,15 +4,33 @@ public sealed class GatewayOptions { public const string SectionName = "MxGateway"; + /// + /// Gets authentication configuration options. + /// public AuthenticationOptions Authentication { get; init; } = new(); + /// + /// Gets worker process configuration options. + /// public WorkerOptions Worker { get; init; } = new(); + /// + /// Gets session management configuration options. + /// public SessionOptions Sessions { get; init; } = new(); + /// + /// Gets event stream configuration options. + /// public EventOptions Events { get; init; } = new(); + /// + /// Gets dashboard configuration options. + /// public DashboardOptions Dashboard { get; init; } = new(); + /// + /// Gets protocol configuration options. + /// public ProtocolOptions Protocol { get; init; } = new(); } diff --git a/src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs index c63884d..4cd0aaa 100644 --- a/src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -8,6 +8,12 @@ public sealed class GatewayOptionsValidator : IValidateOptions private const int MinimumMaxMessageBytes = 1024; private const int MaximumMaxMessageBytes = 256 * 1024 * 1024; + /// + /// Validates gateway configuration options. + /// + /// Options name. + /// Gateway options to validate. + /// Validation result. public ValidateOptionsResult Validate(string? name, GatewayOptions options) { List failures = []; diff --git a/src/MxGateway.Server/Configuration/IGatewayConfigurationProvider.cs b/src/MxGateway.Server/Configuration/IGatewayConfigurationProvider.cs index 6393d19..be240e9 100644 --- a/src/MxGateway.Server/Configuration/IGatewayConfigurationProvider.cs +++ b/src/MxGateway.Server/Configuration/IGatewayConfigurationProvider.cs @@ -1,6 +1,12 @@ namespace MxGateway.Server.Configuration; +/// +/// Provides the effective gateway configuration, applying defaults and validations. +/// public interface IGatewayConfigurationProvider { + /// + /// Returns the validated and effective gateway configuration. + /// EffectiveGatewayConfiguration GetEffectiveConfiguration(); } diff --git a/src/MxGateway.Server/Configuration/ProtocolOptions.cs b/src/MxGateway.Server/Configuration/ProtocolOptions.cs index 4f75ec3..f569a91 100644 --- a/src/MxGateway.Server/Configuration/ProtocolOptions.cs +++ b/src/MxGateway.Server/Configuration/ProtocolOptions.cs @@ -2,7 +2,13 @@ using MxGateway.Contracts; namespace MxGateway.Server.Configuration; +/// +/// Configuration options for the worker protocol version. +/// public sealed class ProtocolOptions { + /// + /// Gets or sets the worker protocol version. + /// public uint WorkerProtocolVersion { get; init; } = GatewayContractInfo.WorkerProtocolVersion; } diff --git a/src/MxGateway.Server/Configuration/SessionOptions.cs b/src/MxGateway.Server/Configuration/SessionOptions.cs index 4fdb001..5b31638 100644 --- a/src/MxGateway.Server/Configuration/SessionOptions.cs +++ b/src/MxGateway.Server/Configuration/SessionOptions.cs @@ -2,11 +2,23 @@ namespace MxGateway.Server.Configuration; public sealed class SessionOptions { + /// + /// Gets the default command timeout in seconds. + /// public int DefaultCommandTimeoutSeconds { get; init; } = 30; + /// + /// Gets the maximum number of concurrent sessions. + /// public int MaxSessions { get; init; } = 64; + /// + /// Gets the maximum number of pending commands per session. + /// public int MaxPendingCommandsPerSession { get; init; } = 128; + /// + /// Gets a value indicating whether multiple event subscribers are allowed per session. + /// public bool AllowMultipleEventSubscribers { get; init; } } diff --git a/src/MxGateway.Server/Configuration/WorkerOptions.cs b/src/MxGateway.Server/Configuration/WorkerOptions.cs index 6be6d23..3081b07 100644 --- a/src/MxGateway.Server/Configuration/WorkerOptions.cs +++ b/src/MxGateway.Server/Configuration/WorkerOptions.cs @@ -2,26 +2,37 @@ namespace MxGateway.Server.Configuration; public sealed class WorkerOptions { + /// The path to the worker executable. public string ExecutablePath { get; init; } = @"src\MxGateway.Worker\bin\x86\Release\MxGateway.Worker.exe"; + /// The working directory for the worker process, or null to inherit. public string? WorkingDirectory { get; init; } + /// The required processor architecture for the worker. public WorkerArchitecture RequiredArchitecture { get; init; } = WorkerArchitecture.X86; + /// The maximum time in seconds for the worker to start. public int StartupTimeoutSeconds { get; init; } = 30; + /// The number of retry attempts for the startup probe. public int StartupProbeRetryAttempts { get; init; } = 3; + /// The delay in milliseconds between startup probe retries. public int StartupProbeRetryDelayMilliseconds { get; init; } = 250; + /// The timeout in milliseconds for connecting to the worker pipe. public int PipeConnectAttemptTimeoutMilliseconds { get; init; } = 2000; + /// The maximum time in seconds for graceful shutdown. public int ShutdownTimeoutSeconds { get; init; } = 10; + /// The interval in seconds for worker heartbeats. public int HeartbeatIntervalSeconds { get; init; } = 5; + /// The grace period in seconds after a heartbeat before considering the worker unresponsive. public int HeartbeatGraceSeconds { get; init; } = 15; + /// The maximum message size in bytes for IPC communication. public int MaxMessageBytes { get; init; } = 16 * 1024 * 1024; } diff --git a/src/MxGateway.Server/Dashboard/Components/DashboardDisplay.cs b/src/MxGateway.Server/Dashboard/Components/DashboardDisplay.cs index f1ab8d0..c844ef9 100644 --- a/src/MxGateway.Server/Dashboard/Components/DashboardDisplay.cs +++ b/src/MxGateway.Server/Dashboard/Components/DashboardDisplay.cs @@ -2,6 +2,11 @@ namespace MxGateway.Server.Dashboard.Components; public static class DashboardDisplay { + /// + /// Formats a nullable date and time value for display. + /// + /// The date and time to format. + /// Formatted date and time string or "-" if null. public static string DateTime(DateTimeOffset? value) { return value.HasValue @@ -9,6 +14,11 @@ public static class DashboardDisplay : "-"; } + /// + /// Formats a time span duration for display. + /// + /// The duration to format. + /// Formatted duration string. public static string Duration(TimeSpan value) { return value.TotalDays >= 1 @@ -16,16 +26,33 @@ public static class DashboardDisplay : value.ToString(@"hh\:mm\:ss", System.Globalization.CultureInfo.InvariantCulture); } + /// + /// Formats a nullable text value for display. + /// + /// The text to format. + /// Formatted text or "-" if null or empty. public static string Text(string? value) { return string.IsNullOrWhiteSpace(value) ? "-" : value; } + /// + /// Formats a long count value for display with thousands separator. + /// + /// The count to format. + /// Formatted count string. public static string Count(long value) { return value.ToString("N0", System.Globalization.CultureInfo.InvariantCulture); } + /// + /// Retrieves a metric value from a snapshot by name and optional dimension. + /// + /// Dashboard snapshot. + /// Metric name. + /// Optional metric dimension. + /// Metric value or zero if not found. public static long MetricValue(DashboardSnapshot snapshot, string name, string? dimension = null) { return snapshot.Metrics.FirstOrDefault(metric => diff --git a/src/MxGateway.Server/Dashboard/Components/DashboardPageBase.cs b/src/MxGateway.Server/Dashboard/Components/DashboardPageBase.cs index 3a5d599..2d4447d 100644 --- a/src/MxGateway.Server/Dashboard/Components/DashboardPageBase.cs +++ b/src/MxGateway.Server/Dashboard/Components/DashboardPageBase.cs @@ -2,21 +2,32 @@ using Microsoft.AspNetCore.Components; namespace MxGateway.Server.Dashboard.Components; +/// +/// Base class for Blazor dashboard pages that watch gateway metrics snapshots. +/// public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable { private readonly CancellationTokenSource _disposeCancellation = new(); private Task? _watchTask; + /// + /// Service that provides gateway metric snapshots. + /// [Inject] protected IDashboardSnapshotService SnapshotService { get; set; } = null!; + /// + /// The most recent gateway metric snapshot, updated as it changes. + /// protected DashboardSnapshot? Snapshot { get; private set; } + /// protected override void OnInitialized() { _watchTask = WatchSnapshotsAsync(); } + /// public async ValueTask DisposeAsync() { await _disposeCancellation.CancelAsync().ConfigureAwait(false); @@ -29,6 +40,9 @@ public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable GC.SuppressFinalize(this); } + /// + /// Watches snapshot changes and triggers component refresh. + /// private async Task WatchSnapshotsAsync() { try diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs b/src/MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs index 36fbadf..22cceac 100644 --- a/src/MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs +++ b/src/MxGateway.Server/Dashboard/DashboardAuthenticationResult.cs @@ -2,16 +2,36 @@ using System.Security.Claims; namespace MxGateway.Server.Dashboard; +/// +/// Result of a dashboard authentication attempt. +/// public sealed record DashboardAuthenticationResult( + /// + /// Whether authentication succeeded. + /// bool Succeeded, + /// + /// The authenticated principal if successful; otherwise null. + /// ClaimsPrincipal? Principal, + /// + /// The failure message if authentication failed; otherwise null. + /// string? FailureMessage) { + /// + /// Creates a successful authentication result. + /// + /// Authenticated principal. public static DashboardAuthenticationResult Success(ClaimsPrincipal principal) { return new DashboardAuthenticationResult(true, principal, null); } + /// + /// Creates a failed authentication result. + /// + /// Diagnostic message describing the failure. public static DashboardAuthenticationResult Fail(string failureMessage) { return new DashboardAuthenticationResult(false, null, failureMessage); diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs b/src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs index b0d269b..5dda85a 100644 --- a/src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs +++ b/src/MxGateway.Server/Dashboard/DashboardAuthenticator.cs @@ -12,6 +12,7 @@ public sealed class DashboardAuthenticator( { private const string GenericFailureMessage = "The API key is invalid or is not authorized for dashboard access."; + /// public async Task AuthenticateAsync( string? apiKey, CancellationToken cancellationToken) diff --git a/src/MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs b/src/MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs index 6158138..d523e54 100644 --- a/src/MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs +++ b/src/MxGateway.Server/Dashboard/DashboardAuthorizationHandler.cs @@ -10,6 +10,7 @@ public sealed class DashboardAuthorizationHandler( IHttpContextAccessor httpContextAccessor, IOptions options) : AuthorizationHandler { + /// protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, DashboardAuthorizationRequirement requirement) diff --git a/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs b/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs index 9164c11..d7ed4d8 100644 --- a/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs +++ b/src/MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs @@ -7,8 +7,12 @@ using MxGateway.Server.Dashboard.Components; namespace MxGateway.Server.Dashboard; +/// Endpoint extensions for registering the gateway dashboard routes. public static class DashboardEndpointRouteBuilderExtensions { + /// Maps all gateway dashboard routes including login, logout, and Razor components. + /// The endpoint route builder. + /// The route builder for chaining. public static IEndpointRouteBuilder MapGatewayDashboard(this IEndpointRouteBuilder endpoints) { IConfiguration configuration = endpoints.ServiceProvider.GetRequiredService(); diff --git a/src/MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs b/src/MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs index 03b6bfe..5425c46 100644 --- a/src/MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs +++ b/src/MxGateway.Server/Dashboard/DashboardGalaxyProjector.cs @@ -8,6 +8,7 @@ namespace MxGateway.Server.Dashboard; /// per-category breakdowns are computed here rather than stored on the cache so the /// Galaxy namespace stays free of dashboard-presentation concepts. /// +/// Projects Galaxy Repository cache entries to dashboard presentation format. internal static class DashboardGalaxyProjector { private const int TopTemplatesLimit = 10; @@ -25,6 +26,9 @@ internal static class DashboardGalaxyProjector [26] = "OPCClient", }; + /// Projects a Galaxy Repository cache entry to a dashboard summary. + /// Galaxy cache entry to project. + /// Dashboard-formatted Galaxy summary. public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry) { DashboardGalaxyStatus status = entry.Status switch diff --git a/src/MxGateway.Server/Dashboard/DashboardGalaxySummary.cs b/src/MxGateway.Server/Dashboard/DashboardGalaxySummary.cs index 769a76b..5742405 100644 --- a/src/MxGateway.Server/Dashboard/DashboardGalaxySummary.cs +++ b/src/MxGateway.Server/Dashboard/DashboardGalaxySummary.cs @@ -19,6 +19,7 @@ public sealed record DashboardGalaxySummary( IReadOnlyList TopTemplates, IReadOnlyList ObjectCategories) { + /// Gets the unknown Galaxy status placeholder. public static DashboardGalaxySummary Unknown { get; } = new( DashboardGalaxyStatus.Unknown, LastQueriedAt: null, diff --git a/src/MxGateway.Server/Dashboard/DashboardRedactor.cs b/src/MxGateway.Server/Dashboard/DashboardRedactor.cs index b46556f..dffc4ca 100644 --- a/src/MxGateway.Server/Dashboard/DashboardRedactor.cs +++ b/src/MxGateway.Server/Dashboard/DashboardRedactor.cs @@ -15,6 +15,11 @@ internal static class DashboardRedactor "token", ]; + /// + /// Redacts sensitive content from a value for dashboard display. + /// + /// Value to redact. + /// Redacted value or original value if not sensitive. public static string? Redact(string? value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs index a8d318a..87b51a0 100644 --- a/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs +++ b/src/MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs @@ -5,8 +5,15 @@ using MxGateway.Server.Configuration; namespace MxGateway.Server.Dashboard; +/// +/// Extension methods for configuring the gateway dashboard services. +/// public static class DashboardServiceCollectionExtensions { + /// + /// Registers all dashboard services, authentication, and Razor components. + /// + /// Service collection to register services. public static IServiceCollection AddGatewayDashboard(this IServiceCollection services) { services.AddSingleton(); diff --git a/src/MxGateway.Server/Dashboard/DashboardSnapshotService.cs b/src/MxGateway.Server/Dashboard/DashboardSnapshotService.cs index f996503..1ccd482 100644 --- a/src/MxGateway.Server/Dashboard/DashboardSnapshotService.cs +++ b/src/MxGateway.Server/Dashboard/DashboardSnapshotService.cs @@ -22,6 +22,13 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService private readonly int _recentFaultLimit; private readonly int _recentSessionLimit; + /// Initializes a new instance of the DashboardSnapshotService class. + /// Registry of active gateway sessions. + /// Gateway metrics collector. + /// Gateway configuration provider. + /// Galaxy hierarchy cache. + /// Gateway configuration options. + /// Provider for current time; defaults to system time. public DashboardSnapshotService( ISessionRegistry sessionRegistry, GatewayMetrics metrics, @@ -43,6 +50,10 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService _recentSessionLimit = options.Value.Dashboard.RecentSessionLimit; } + /// + /// Gets a current dashboard snapshot of gateway state. + /// + /// Dashboard snapshot. public DashboardSnapshot GetSnapshot() { DateTimeOffset generatedAt = _timeProvider.GetUtcNow(); @@ -73,6 +84,11 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current)); } + /// + /// Watches dashboard snapshots at regular intervals asynchronously. + /// + /// Cancellation token. + /// Async enumerable of dashboard snapshots. public async IAsyncEnumerable WatchSnapshotsAsync( [EnumeratorCancellation] CancellationToken cancellationToken) { diff --git a/src/MxGateway.Server/Dashboard/IDashboardAuthenticator.cs b/src/MxGateway.Server/Dashboard/IDashboardAuthenticator.cs index 5c8c330..2bd2c96 100644 --- a/src/MxGateway.Server/Dashboard/IDashboardAuthenticator.cs +++ b/src/MxGateway.Server/Dashboard/IDashboardAuthenticator.cs @@ -1,7 +1,15 @@ namespace MxGateway.Server.Dashboard; +/// +/// Authenticates dashboard access with API keys. +/// public interface IDashboardAuthenticator { + /// + /// Authenticates the dashboard session with an API key. + /// + /// The API key to authenticate. + /// Token to cancel the asynchronous operation. Task AuthenticateAsync( string? apiKey, CancellationToken cancellationToken); diff --git a/src/MxGateway.Server/Dashboard/IDashboardSnapshotService.cs b/src/MxGateway.Server/Dashboard/IDashboardSnapshotService.cs index 452c78d..37f2298 100644 --- a/src/MxGateway.Server/Dashboard/IDashboardSnapshotService.cs +++ b/src/MxGateway.Server/Dashboard/IDashboardSnapshotService.cs @@ -1,8 +1,18 @@ namespace MxGateway.Server.Dashboard; +/// +/// Provides snapshots of the dashboard state for UI updates. +/// public interface IDashboardSnapshotService { + /// + /// Gets the current dashboard snapshot. + /// DashboardSnapshot GetSnapshot(); + /// + /// Watches for changes to the dashboard state as an async enumerable. + /// + /// Token to cancel the asynchronous operation. IAsyncEnumerable WatchSnapshotsAsync(CancellationToken cancellationToken); } diff --git a/src/MxGateway.Server/Diagnostics/GatewayLogRedactor.cs b/src/MxGateway.Server/Diagnostics/GatewayLogRedactor.cs index 2cf4d0a..1f98bc3 100644 --- a/src/MxGateway.Server/Diagnostics/GatewayLogRedactor.cs +++ b/src/MxGateway.Server/Diagnostics/GatewayLogRedactor.cs @@ -1,7 +1,11 @@ namespace MxGateway.Server.Diagnostics; +/// +/// Redacts sensitive information from log entries. +/// public static class GatewayLogRedactor { + /// Placeholder for redacted values. public const string RedactedValue = "[redacted]"; private static readonly HashSet SensitiveCommandMethods = new(StringComparer.OrdinalIgnoreCase) @@ -11,12 +15,20 @@ public static class GatewayLogRedactor "WriteSecured2" }; + /// + /// Determines whether a command method bears credentials. + /// + /// The command method name to check. public static bool IsCredentialBearingCommand(string? commandMethod) { return commandMethod is not null && SensitiveCommandMethods.Contains(commandMethod); } + /// + /// Redacts the API key secret portion of a Bearer authorization header. + /// + /// The authorization header value to redact. public static string? RedactApiKey(string? authorizationHeader) { if (string.IsNullOrWhiteSpace(authorizationHeader)) @@ -46,6 +58,10 @@ public static class GatewayLogRedactor return $"{bearerPrefix}mxgw_{tokenParts[1]}_{RedactedValue}"; } + /// + /// Redacts the client identity if it contains an API key. + /// + /// The client identity string to redact. public static string? RedactClientIdentity(string? clientIdentity) { if (string.IsNullOrWhiteSpace(clientIdentity)) @@ -58,6 +74,12 @@ public static class GatewayLogRedactor : clientIdentity; } + /// + /// Redacts a command value if it contains credentials or value logging is disabled. + /// + /// The command method name to check for credentials. + /// The command value to redact. + /// Whether value logging is enabled. public static object? RedactCommandValue( string? commandMethod, object? value, diff --git a/src/MxGateway.Server/Diagnostics/GatewayLogScope.cs b/src/MxGateway.Server/Diagnostics/GatewayLogScope.cs index d3c073c..4157f02 100644 --- a/src/MxGateway.Server/Diagnostics/GatewayLogScope.cs +++ b/src/MxGateway.Server/Diagnostics/GatewayLogScope.cs @@ -7,6 +7,7 @@ public sealed record GatewayLogScope( string? CommandMethod = null, string? ClientIdentity = null) { + /// Converts the log scope to a dictionary with redacted sensitive fields. public IReadOnlyDictionary ToDictionary() { Dictionary values = []; diff --git a/src/MxGateway.Server/Diagnostics/GatewayLoggerExtensions.cs b/src/MxGateway.Server/Diagnostics/GatewayLoggerExtensions.cs index 69ff90e..22229bd 100644 --- a/src/MxGateway.Server/Diagnostics/GatewayLoggerExtensions.cs +++ b/src/MxGateway.Server/Diagnostics/GatewayLoggerExtensions.cs @@ -4,6 +4,10 @@ namespace MxGateway.Server.Diagnostics; public static class GatewayLoggerExtensions { + /// Begins a gateway log scope with the specified scope properties. + /// Logger used for diagnostic output. + /// Scope properties to apply. + /// A disposable that ends the scope when disposed. public static IDisposable? BeginGatewayScope( this ILogger logger, GatewayLogScope scope) diff --git a/src/MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs b/src/MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs index 8a86cff..96f1499 100644 --- a/src/MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs +++ b/src/MxGateway.Server/Diagnostics/GatewayRequestLoggingMiddlewareExtensions.cs @@ -2,13 +2,23 @@ using Microsoft.Extensions.Primitives; namespace MxGateway.Server.Diagnostics; +/// Middleware extensions for structured gateway request logging with correlation context. public static class GatewayRequestLoggingMiddlewareExtensions { + /// Header name for the session ID. public const string SessionIdHeaderName = "x-session-id"; + + /// Header name for the worker process ID. public const string WorkerProcessIdHeaderName = "x-worker-process-id"; + + /// Header name for the correlation ID. public const string CorrelationIdHeaderName = "x-correlation-id"; + + /// Header name for the command method name. public const string CommandMethodHeaderName = "x-command-method"; + /// Adds gateway request logging scope middleware that reads correlation headers and redacts sensitive data. + /// Application builder. public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app) { ArgumentNullException.ThrowIfNull(app); diff --git a/src/MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs b/src/MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs index 3793d18..55d4d57 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs +++ b/src/MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs @@ -10,6 +10,9 @@ namespace MxGateway.Server.Galaxy; /// other subscribers or the publisher. When a subscriber's channel is full the oldest /// event is dropped — clients use the sequence field to detect gaps. /// +/// +/// Publishes Galaxy deploy events to streaming gRPC subscribers via private bounded channels. +/// public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier { private const int SubscriberQueueCapacity = 16; @@ -17,8 +20,12 @@ public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier private readonly ConcurrentDictionary> _subscribers = new(); private GalaxyDeployEventInfo? _latest; + /// + /// The most recent deploy event, or null if none has been published. + /// public GalaxyDeployEventInfo? Latest => Volatile.Read(ref _latest); + /// public void Publish(GalaxyDeployEventInfo info) { ArgumentNullException.ThrowIfNull(info); @@ -33,6 +40,7 @@ public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier } } + /// public async IAsyncEnumerable SubscribeAsync( [EnumeratorCancellation] CancellationToken cancellationToken) { diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs b/src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs index 3f0b7f1..18aca17 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs +++ b/src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs @@ -25,6 +25,11 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache private readonly SemaphoreSlim _refreshGate = new(1, 1); private GalaxyHierarchyCacheEntry _current = GalaxyHierarchyCacheEntry.Empty; + /// Initializes a new instance of the class. + /// Galaxy Repository client for SQL queries. + /// Galaxy deploy event notifier. + /// Provider for current time; defaults to system time. + /// Optional logger for diagnostic output. public GalaxyHierarchyCache( GalaxyRepository repository, IGalaxyDeployNotifier notifier, @@ -37,6 +42,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache _logger = logger; } + /// Gets the current Galaxy hierarchy cache entry with projected status. public GalaxyHierarchyCacheEntry Current { get @@ -47,6 +53,9 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache } } + /// Refreshes the Galaxy hierarchy cache if the deploy time has advanced. + /// Token to cancel the asynchronous operation. + /// Asynchronous task representing the refresh operation. public async Task RefreshAsync(CancellationToken cancellationToken) { await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -60,6 +69,9 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache } } + /// Waits for the Galaxy hierarchy cache to complete its first load. + /// Token to cancel the asynchronous operation. + /// Asynchronous task representing the wait operation. public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) { return _firstLoad.Task.WaitAsync(cancellationToken); diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs b/src/MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs index 3b7fcf2..a7c6c19 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs +++ b/src/MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs @@ -23,6 +23,7 @@ public sealed record GalaxyHierarchyCacheEntry( int HistorizedAttributeCount, int AlarmAttributeCount) { + /// Gets an empty Galaxy hierarchy cache entry. public static GalaxyHierarchyCacheEntry Empty { get; } = new( Status: GalaxyCacheStatus.Unknown, Sequence: 0, @@ -39,5 +40,6 @@ public sealed record GalaxyHierarchyCacheEntry( HistorizedAttributeCount: 0, AlarmAttributeCount: 0); + /// Gets a value indicating whether the cache entry contains usable data. public bool HasData => Status is GalaxyCacheStatus.Healthy or GalaxyCacheStatus.Stale; } diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs b/src/MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs index 063039e..629a601 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs +++ b/src/MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs @@ -4,11 +4,7 @@ using Microsoft.Extensions.Options; namespace MxGateway.Server.Galaxy; -/// -/// Periodically refreshes off the request path. The -/// interval comes from ; -/// each tick is cheap when the deploy timestamp is unchanged. -/// +/// Background service that periodically refreshes the Galaxy Repository hierarchy cache off the request path. public sealed class GalaxyHierarchyRefreshService( IGalaxyHierarchyCache cache, IOptions options, @@ -17,6 +13,7 @@ public sealed class GalaxyHierarchyRefreshService( { private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; + /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, options.Value.DashboardRefreshIntervalSeconds)); diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyRow.cs b/src/MxGateway.Server/Galaxy/GalaxyHierarchyRow.cs index 8f0a10b..6df9449 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyHierarchyRow.cs +++ b/src/MxGateway.Server/Galaxy/GalaxyHierarchyRow.cs @@ -6,30 +6,70 @@ namespace MxGateway.Server.Galaxy; /// public sealed class GalaxyHierarchyRow { + /// Gets the Galaxy object identifier. public int GobjectId { get; init; } + + /// Gets the tag name. public string TagName { get; init; } = string.Empty; + + /// Gets the contained name. public string ContainedName { get; init; } = string.Empty; + + /// Gets the browse name. public string BrowseName { get; init; } = string.Empty; + + /// Gets the parent Galaxy object identifier. public int ParentGobjectId { get; init; } + + /// Gets a value indicating whether this is an area. public bool IsArea { get; init; } + + /// Gets the category identifier. public int CategoryId { get; init; } + + /// Gets the Galaxy object identifier of the host. public int HostedByGobjectId { get; init; } + + /// Gets the template derivation chain. public IReadOnlyList TemplateChain { get; init; } = Array.Empty(); } /// One row from . public sealed class GalaxyAttributeRow { + /// Gets the Galaxy object identifier. public int GobjectId { get; init; } + + /// Gets the tag name. public string TagName { get; init; } = string.Empty; + + /// Gets the attribute name. public string AttributeName { get; init; } = string.Empty; + + /// Gets the full tag reference. public string FullTagReference { get; init; } = string.Empty; + + /// Gets the MXAccess data type code. public int MxDataType { get; init; } + + /// Gets the data type name. public string? DataTypeName { get; init; } + + /// Gets a value indicating whether this is an array. public bool IsArray { get; init; } + + /// Gets the array dimension, if applicable. public int? ArrayDimension { get; init; } + + /// Gets the MXAccess attribute category code. public int MxAttributeCategory { get; init; } + + /// Gets the security classification code. public int SecurityClassification { get; init; } + + /// Gets a value indicating whether this is historized. public bool IsHistorized { get; init; } + + /// Gets a value indicating whether this is an alarm. public bool IsAlarm { get; init; } } diff --git a/src/MxGateway.Server/Galaxy/GalaxyRepository.cs b/src/MxGateway.Server/Galaxy/GalaxyRepository.cs index 07a3a92..2e27499 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyRepository.cs +++ b/src/MxGateway.Server/Galaxy/GalaxyRepository.cs @@ -10,6 +10,8 @@ namespace MxGateway.Server.Galaxy; /// public sealed class GalaxyRepository(GalaxyRepositoryOptions options) { + /// Tests the connection to the Galaxy Repository database. + /// Token to cancel the asynchronous operation. public async Task TestConnectionAsync(CancellationToken ct = default) { try @@ -24,6 +26,8 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) catch (InvalidOperationException) { return false; } } + /// Retrieves the last deployment time from the Galaxy Repository. + /// Token to cancel the asynchronous operation. public async Task GetLastDeployTimeAsync(CancellationToken ct = default) { using SqlConnection conn = new(options.ConnectionString); @@ -34,6 +38,8 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) return result is DateTime dt ? dt : null; } + /// Retrieves the complete hierarchy of Galaxy objects from the repository. + /// Token to cancel the asynchronous operation. public async Task> GetHierarchyAsync(CancellationToken ct = default) { List rows = new(); @@ -70,6 +76,8 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) return rows; } + /// Retrieves all attributes for Galaxy objects from the repository. + /// Token to cancel the asynchronous operation. public async Task> GetAttributesAsync(CancellationToken ct = default) { List rows = new(); diff --git a/src/MxGateway.Server/Galaxy/GalaxyRepositoryOptions.cs b/src/MxGateway.Server/Galaxy/GalaxyRepositoryOptions.cs index e2e12c9..921b1d7 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyRepositoryOptions.cs +++ b/src/MxGateway.Server/Galaxy/GalaxyRepositoryOptions.cs @@ -8,9 +8,11 @@ public sealed class GalaxyRepositoryOptions { public const string SectionName = "MxGateway:Galaxy"; + /// The SQL Server connection string for the Galaxy Repository database. public string ConnectionString { get; init; } = "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;"; + /// The timeout in seconds for SQL commands executed against the Galaxy Repository. public int CommandTimeoutSeconds { get; init; } = 60; /// diff --git a/src/MxGateway.Server/Galaxy/GalaxyRepositoryServiceCollectionExtensions.cs b/src/MxGateway.Server/Galaxy/GalaxyRepositoryServiceCollectionExtensions.cs index dcff412..35cf727 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyRepositoryServiceCollectionExtensions.cs +++ b/src/MxGateway.Server/Galaxy/GalaxyRepositoryServiceCollectionExtensions.cs @@ -4,6 +4,9 @@ namespace MxGateway.Server.Galaxy; public static class GalaxyRepositoryServiceCollectionExtensions { + /// Registers Galaxy Repository services in the dependency injection container. + /// The service collection. + /// The service collection for chaining. public static IServiceCollection AddGalaxyRepository(this IServiceCollection services) { services diff --git a/src/MxGateway.Server/Galaxy/IGalaxyDeployNotifier.cs b/src/MxGateway.Server/Galaxy/IGalaxyDeployNotifier.cs index 85593f9..6bcef2e 100644 --- a/src/MxGateway.Server/Galaxy/IGalaxyDeployNotifier.cs +++ b/src/MxGateway.Server/Galaxy/IGalaxyDeployNotifier.cs @@ -1,18 +1,17 @@ namespace MxGateway.Server.Galaxy; +/// Publishes Galaxy repository deploy events to subscribers. public interface IGalaxyDeployNotifier { - /// The most recently published event, or null if no event has fired yet. + /// The most recently published event, or null if no event has fired yet. GalaxyDeployEventInfo? Latest { get; } - /// Publishes a deploy event to all current subscribers and stores it as . + /// Publishes a deploy event to all current subscribers and stores it as Latest. + /// The deploy event to publish. void Publish(GalaxyDeployEventInfo info); - /// - /// Subscribe to deploy events. The async sequence yields events as they fire. If - /// is set, it is yielded first so subscribers can bootstrap their - /// local cache without waiting for the next deploy. Pass a cancellation token to - /// unsubscribe. - /// + /// Subscribes to deploy events. The sequence yields the latest event first (if available) then streams new events as they fire. + /// Token to cancel the asynchronous operation. + /// Async enumerable of deploy events. IAsyncEnumerable SubscribeAsync(CancellationToken cancellationToken); } diff --git a/src/MxGateway.Server/Galaxy/IGalaxyHierarchyCache.cs b/src/MxGateway.Server/Galaxy/IGalaxyHierarchyCache.cs index 301edee..027a867 100644 --- a/src/MxGateway.Server/Galaxy/IGalaxyHierarchyCache.cs +++ b/src/MxGateway.Server/Galaxy/IGalaxyHierarchyCache.cs @@ -1,5 +1,6 @@ namespace MxGateway.Server.Galaxy; +/// Cache for Galaxy Repository hierarchy data. public interface IGalaxyHierarchyCache { /// The latest cache entry. Status freshness is recomputed against the clock. @@ -11,6 +12,7 @@ public interface IGalaxyHierarchyCache /// attributes rowsets when the deploy time has changed since the last successful /// refresh. /// + /// Token to cancel the asynchronous operation. Task RefreshAsync(CancellationToken cancellationToken); /// @@ -18,5 +20,6 @@ public interface IGalaxyHierarchyCache /// gRPC handlers that want to serve from cache without returning Unavailable on the /// very first request after gateway start. /// + /// Token to cancel the asynchronous operation. Task WaitForFirstLoadAsync(CancellationToken cancellationToken); } diff --git a/src/MxGateway.Server/GatewayApplication.cs b/src/MxGateway.Server/GatewayApplication.cs index 53b8c0d..bb19b90 100644 --- a/src/MxGateway.Server/GatewayApplication.cs +++ b/src/MxGateway.Server/GatewayApplication.cs @@ -13,10 +13,18 @@ using MxGateway.Server.Workers; namespace MxGateway.Server; +/// +/// Configures and builds the gateway web application. +/// public static class GatewayApplication { private const string StaticAssetsManifestFileName = "MxGateway.Server.staticwebassets.endpoints.json"; + /// + /// Builds a configured web application with all gateway services and middleware. + /// + /// Command-line arguments passed to the application. + /// A configured web application ready to run. public static WebApplication Build(string[] args) { WebApplicationBuilder builder = CreateBuilder(args); @@ -32,6 +40,11 @@ public static class GatewayApplication return app; } + /// + /// Creates a web application builder configured with gateway services. + /// + /// Command-line arguments passed to the application. + /// A configured web application builder. public static WebApplicationBuilder CreateBuilder(string[] args) { WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions @@ -112,6 +125,11 @@ public static class GatewayApplication && Directory.Exists(Path.Combine(path, "wwwroot")); } + /// + /// Maps gateway endpoints including gRPC services, health checks, and the dashboard. + /// + /// Endpoint route builder to map endpoints to. + /// The same endpoint route builder for chaining. public static IEndpointRouteBuilder MapGatewayEndpoints(this IEndpointRouteBuilder endpoints) { endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath()); diff --git a/src/MxGateway.Server/Grpc/EventStreamService.cs b/src/MxGateway.Server/Grpc/EventStreamService.cs index 31e965a..11ae968 100644 --- a/src/MxGateway.Server/Grpc/EventStreamService.cs +++ b/src/MxGateway.Server/Grpc/EventStreamService.cs @@ -16,6 +16,12 @@ public sealed class EventStreamService( GatewayMetrics metrics, ILogger logger) : IEventStreamService { + /// + /// Streams events from a session to the client asynchronously. + /// + /// Stream events request. + /// Cancellation token. + /// Async enumerable of MX events. public async IAsyncEnumerable StreamEventsAsync( StreamEventsRequest request, [EnumeratorCancellation] CancellationToken cancellationToken) diff --git a/src/MxGateway.Server/Grpc/GalaxyProtoMapper.cs b/src/MxGateway.Server/Grpc/GalaxyProtoMapper.cs index 3ca660d..4a1f258 100644 --- a/src/MxGateway.Server/Grpc/GalaxyProtoMapper.cs +++ b/src/MxGateway.Server/Grpc/GalaxyProtoMapper.cs @@ -10,6 +10,9 @@ namespace MxGateway.Server.Grpc; /// public static class GalaxyProtoMapper { + /// Maps Galaxy hierarchy and attribute rows to Galaxy object protos. + /// Hierarchy rows from Galaxy Repository. + /// Attribute rows from Galaxy Repository. public static IEnumerable MapHierarchy( IReadOnlyList hierarchy, IReadOnlyList attributes) @@ -24,6 +27,9 @@ public static class GalaxyProtoMapper } } + /// Maps a Galaxy hierarchy row to a Galaxy object proto. + /// Hierarchy row from Galaxy Repository. + /// Attributes indexed by gobject ID. public static GalaxyObject MapObject( GalaxyHierarchyRow row, IReadOnlyDictionary> attributesByGobjectId) @@ -52,6 +58,8 @@ public static class GalaxyProtoMapper return obj; } + /// Maps a Galaxy attribute row to a Galaxy attribute proto. + /// Attribute row from Galaxy Repository. public static GalaxyAttribute MapAttribute(GalaxyAttributeRow row) => new() { AttributeName = row.AttributeName, diff --git a/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs b/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs index 68ed9f1..c3a0d2d 100644 --- a/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs +++ b/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs @@ -22,6 +22,7 @@ public sealed class GalaxyRepositoryGrpcService( { private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5); + /// public override async Task TestConnection( TestConnectionRequest request, ServerCallContext context) @@ -30,6 +31,7 @@ public sealed class GalaxyRepositoryGrpcService( return new TestConnectionReply { Ok = ok }; } + /// public override async Task GetLastDeployTime( GetLastDeployTimeRequest request, ServerCallContext context) @@ -52,6 +54,7 @@ public sealed class GalaxyRepositoryGrpcService( return reply; } + /// public override async Task DiscoverHierarchy( DiscoverHierarchyRequest request, ServerCallContext context) @@ -71,6 +74,7 @@ public sealed class GalaxyRepositoryGrpcService( return entry.Reply; } + /// public override async Task WatchDeployEvents( WatchDeployEventsRequest request, IServerStreamWriter responseStream, diff --git a/src/MxGateway.Server/Grpc/IEventStreamService.cs b/src/MxGateway.Server/Grpc/IEventStreamService.cs index 06f4acb..5996a81 100644 --- a/src/MxGateway.Server/Grpc/IEventStreamService.cs +++ b/src/MxGateway.Server/Grpc/IEventStreamService.cs @@ -2,8 +2,16 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Server.Grpc; +/// +/// Streams MXAccess events to gRPC clients. +/// public interface IEventStreamService { + /// + /// Streams events for the specified session to the caller. + /// + /// Request payload. + /// Token to cancel the asynchronous operation. IAsyncEnumerable StreamEventsAsync( StreamEventsRequest request, CancellationToken cancellationToken); diff --git a/src/MxGateway.Server/Grpc/MxAccessGatewayService.cs b/src/MxGateway.Server/Grpc/MxAccessGatewayService.cs index 53f7f0f..5d6fe42 100644 --- a/src/MxGateway.Server/Grpc/MxAccessGatewayService.cs +++ b/src/MxGateway.Server/Grpc/MxAccessGatewayService.cs @@ -9,6 +9,7 @@ using MxGateway.Server.Workers; namespace MxGateway.Server.Grpc; +/// gRPC service implementation for MXAccess Gateway operations. public sealed class MxAccessGatewayService( ISessionManager sessionManager, IGatewayRequestIdentityAccessor identityAccessor, @@ -18,6 +19,7 @@ public sealed class MxAccessGatewayService( GatewayMetrics metrics, ILogger logger) : MxAccessGateway.MxAccessGatewayBase { + /// public override async Task OpenSession( OpenSessionRequest request, ServerCallContext context) @@ -56,6 +58,7 @@ public sealed class MxAccessGatewayService( } } + /// public override async Task CloseSession( CloseSessionRequest request, ServerCallContext context) @@ -80,6 +83,7 @@ public sealed class MxAccessGatewayService( } } + /// public override async Task Invoke( MxCommandRequest request, ServerCallContext context) @@ -100,6 +104,7 @@ public sealed class MxAccessGatewayService( } } + /// public override async Task StreamEvents( StreamEventsRequest request, IServerStreamWriter responseStream, diff --git a/src/MxGateway.Server/Grpc/MxAccessGrpcMapper.cs b/src/MxGateway.Server/Grpc/MxAccessGrpcMapper.cs index 522b5c8..ff606e5 100644 --- a/src/MxGateway.Server/Grpc/MxAccessGrpcMapper.cs +++ b/src/MxGateway.Server/Grpc/MxAccessGrpcMapper.cs @@ -3,15 +3,26 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Server.Grpc; +/// +/// Maps between worker IPC types and gRPC contract types. +/// public sealed class MxAccessGrpcMapper { private readonly TimeProvider _timeProvider; + /// + /// Initializes the mapper with an optional time provider. + /// + /// Time provider for timestamps; defaults to system time if null. public MxAccessGrpcMapper(TimeProvider? timeProvider = null) { _timeProvider = timeProvider ?? TimeProvider.System; } + /// + /// Maps a gRPC MX command request to a worker command. + /// + /// Request payload. public WorkerCommand MapCommand(MxCommandRequest request) { ArgumentNullException.ThrowIfNull(request); @@ -24,6 +35,10 @@ public sealed class MxAccessGrpcMapper }; } + /// + /// Maps a worker command reply to a gRPC MX command reply. + /// + /// Worker command reply. public MxCommandReply MapCommandReply(WorkerCommandReply reply) { ArgumentNullException.ThrowIfNull(reply); @@ -39,6 +54,10 @@ public sealed class MxAccessGrpcMapper return reply.Reply.Clone(); } + /// + /// Maps a worker event to a gRPC MX event. + /// + /// Worker event to map. public MxEvent MapEvent(WorkerEvent workerEvent) { ArgumentNullException.ThrowIfNull(workerEvent); @@ -50,6 +69,10 @@ public sealed class MxAccessGrpcMapper }; } + /// + /// Creates an OK protocol status. + /// + /// Status message. public static ProtocolStatus Ok(string message = "OK") { return new ProtocolStatus @@ -59,6 +82,10 @@ public sealed class MxAccessGrpcMapper }; } + /// + /// Creates an InvalidRequest protocol status. + /// + /// Status message. public static ProtocolStatus InvalidRequest(string message) { return new ProtocolStatus @@ -68,6 +95,10 @@ public sealed class MxAccessGrpcMapper }; } + /// + /// Creates a SessionNotFound protocol status. + /// + /// Status message. public static ProtocolStatus SessionNotFound(string message) { return new ProtocolStatus @@ -77,6 +108,10 @@ public sealed class MxAccessGrpcMapper }; } + /// + /// Creates a SessionNotReady protocol status. + /// + /// Status message. public static ProtocolStatus SessionNotReady(string message) { return new ProtocolStatus @@ -86,6 +121,10 @@ public sealed class MxAccessGrpcMapper }; } + /// + /// Creates a WorkerUnavailable protocol status. + /// + /// Status message. public static ProtocolStatus WorkerUnavailable(string message) { return new ProtocolStatus @@ -95,6 +134,10 @@ public sealed class MxAccessGrpcMapper }; } + /// + /// Creates a Timeout protocol status. + /// + /// Status message. public static ProtocolStatus Timeout(string message) { return new ProtocolStatus @@ -104,6 +147,10 @@ public sealed class MxAccessGrpcMapper }; } + /// + /// Creates a Canceled protocol status. + /// + /// Status message. public static ProtocolStatus Canceled(string message) { return new ProtocolStatus @@ -113,6 +160,10 @@ public sealed class MxAccessGrpcMapper }; } + /// + /// Creates a ProtocolViolation protocol status. + /// + /// Status message. public static ProtocolStatus ProtocolViolation(string message) { return new ProtocolStatus diff --git a/src/MxGateway.Server/Grpc/MxAccessGrpcRequestValidator.cs b/src/MxGateway.Server/Grpc/MxAccessGrpcRequestValidator.cs index e6822bd..ee81dc2 100644 --- a/src/MxGateway.Server/Grpc/MxAccessGrpcRequestValidator.cs +++ b/src/MxGateway.Server/Grpc/MxAccessGrpcRequestValidator.cs @@ -5,6 +5,8 @@ namespace MxGateway.Server.Grpc; public sealed class MxAccessGrpcRequestValidator { + /// Validates an open session request. + /// The request to validate. public void ValidateOpenSession(OpenSessionRequest request) { ArgumentNullException.ThrowIfNull(request); @@ -15,18 +17,24 @@ public sealed class MxAccessGrpcRequestValidator } } + /// Validates a close session request. + /// The request to validate. public void ValidateCloseSession(CloseSessionRequest request) { ArgumentNullException.ThrowIfNull(request); RequireSessionId(request.SessionId); } + /// Validates a stream events request. + /// The request to validate. public void ValidateStreamEvents(StreamEventsRequest request) { ArgumentNullException.ThrowIfNull(request); RequireSessionId(request.SessionId); } + /// Validates an invoke request with command payload. + /// The request to validate. public void ValidateInvoke(MxCommandRequest request) { ArgumentNullException.ThrowIfNull(request); diff --git a/src/MxGateway.Server/Metrics/GatewayMetrics.cs b/src/MxGateway.Server/Metrics/GatewayMetrics.cs index af9c0c0..05145c0 100644 --- a/src/MxGateway.Server/Metrics/GatewayMetrics.cs +++ b/src/MxGateway.Server/Metrics/GatewayMetrics.cs @@ -49,6 +49,9 @@ public sealed class GatewayMetrics : IDisposable private long _retryAttempts; private bool _disposed; + /// + /// Initializes the gateway metrics with OpenTelemetry counters and histograms. + /// public GatewayMetrics() { _meter = new Meter(MeterName, typeof(GatewayMetrics).Assembly.GetName().Version?.ToString()); @@ -75,6 +78,9 @@ public sealed class GatewayMetrics : IDisposable _meter.CreateObservableGauge("mxgateway.events.grpc_stream_queue.depth", GetGrpcEventStreamQueueDepth); } + /// + /// Records that a session has been opened. + /// public void SessionOpened() { lock (_syncRoot) @@ -86,6 +92,9 @@ public sealed class GatewayMetrics : IDisposable _sessionsOpenedCounter.Add(1); } + /// + /// Records that a session has been closed. + /// public void SessionClosed() { lock (_syncRoot) @@ -101,6 +110,9 @@ public sealed class GatewayMetrics : IDisposable _sessionsClosedCounter.Add(1); } + /// + /// Records that a session has been removed from registry. + /// public void SessionRemoved() { lock (_syncRoot) @@ -112,6 +124,10 @@ public sealed class GatewayMetrics : IDisposable } } + /// + /// Records that a worker process has started and its startup latency. + /// + /// Duration elapsed while starting the worker. public void WorkerStarted(TimeSpan startupDuration) { lock (_syncRoot) @@ -122,6 +138,10 @@ public sealed class GatewayMetrics : IDisposable _workerStartupLatencyHistogram.Record(startupDuration.TotalMilliseconds); } + /// + /// Records that a worker process has stopped with the given reason. + /// + /// Cause of the worker stopping. public void WorkerStopped(string reason) { lock (_syncRoot) @@ -137,6 +157,10 @@ public sealed class GatewayMetrics : IDisposable _workerExitsCounter.Add(1, new KeyValuePair("reason", reason)); } + /// + /// Records that a worker process was killed with the given reason. + /// + /// Cause of the worker termination. public void WorkerKilled(string reason) { lock (_syncRoot) @@ -147,6 +171,10 @@ public sealed class GatewayMetrics : IDisposable _workerKillsCounter.Add(1, new KeyValuePair("reason", reason)); } + /// + /// Records that a command has started for the given method. + /// + /// Name of the command method. public void CommandStarted(string method) { lock (_syncRoot) @@ -157,6 +185,11 @@ public sealed class GatewayMetrics : IDisposable _commandsStartedCounter.Add(1, new KeyValuePair("method", method)); } + /// + /// Records that a command succeeded for the given method and duration. + /// + /// Name of the command method. + /// Elapsed time to complete the command. public void CommandSucceeded(string method, TimeSpan duration) { lock (_syncRoot) @@ -169,6 +202,12 @@ public sealed class GatewayMetrics : IDisposable _commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag); } + /// + /// Records that a command failed for the given method, category, and duration. + /// + /// Name of the command method. + /// Classification of the failure. + /// Elapsed time before command failed. public void CommandFailed(string method, string category, TimeSpan duration) { lock (_syncRoot) @@ -183,6 +222,11 @@ public sealed class GatewayMetrics : IDisposable _commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag, categoryTag); } + /// + /// Records that an event was received for the given session and family. + /// + /// Identifier of the session receiving the event. + /// Event family classification. public void EventReceived(string sessionId, string family) { Interlocked.Increment(ref _eventsReceived); @@ -194,6 +238,11 @@ public sealed class GatewayMetrics : IDisposable new KeyValuePair("family", family)); } + /// + /// Records the latency of sending an event to a client stream. + /// + /// Event family name. + /// Time taken to send the event. public void RecordEventStreamSend(string family, TimeSpan duration) { _eventStreamSendLatencyHistogram.Record( @@ -201,11 +250,19 @@ public sealed class GatewayMetrics : IDisposable new KeyValuePair("family", family)); } + /// + /// Sets the worker event queue depth; delegates to SetWorkerEventQueueDepth. + /// + /// Queue depth value. public void SetEventQueueDepth(int depth) { SetWorkerEventQueueDepth(depth); } + /// + /// Sets the worker event queue depth to the given value. + /// + /// Queue depth value. public void SetWorkerEventQueueDepth(int depth) { if (depth < 0) @@ -219,6 +276,10 @@ public sealed class GatewayMetrics : IDisposable } } + /// + /// Adjusts the gRPC event stream queue depth by the given delta. + /// + /// Amount to adjust the queue depth by. public void AdjustGrpcEventStreamQueueDepth(int delta) { lock (_syncRoot) @@ -227,11 +288,19 @@ public sealed class GatewayMetrics : IDisposable } } + /// + /// Removes event counters for the given session. + /// + /// Identifier of the session. public void RemoveSessionEvents(string sessionId) { _eventsBySession.TryRemove(sessionId, out _); } + /// + /// Records that a queue overflow occurred for the given queue name. + /// + /// Name of the queue that overflowed. public void QueueOverflow(string queueName) { lock (_syncRoot) @@ -242,6 +311,10 @@ public sealed class GatewayMetrics : IDisposable _queueOverflowsCounter.Add(1, new KeyValuePair("queue", queueName)); } + /// + /// Records that a fault occurred in the given category. + /// + /// Category of the fault. public void Fault(string category) { lock (_syncRoot) @@ -252,6 +325,10 @@ public sealed class GatewayMetrics : IDisposable _faultsCounter.Add(1, new KeyValuePair("category", category)); } + /// + /// Records that a heartbeat failed for the given session. + /// + /// Identifier of the session. public void HeartbeatFailed(string sessionId) { lock (_syncRoot) @@ -262,6 +339,10 @@ public sealed class GatewayMetrics : IDisposable _heartbeatFailuresCounter.Add(1, new KeyValuePair("session_id", sessionId)); } + /// + /// Records that an event stream was disconnected with the given reason. + /// + /// Reason for the disconnection. public void StreamDisconnected(string reason) { lock (_syncRoot) @@ -272,6 +353,10 @@ public sealed class GatewayMetrics : IDisposable _streamDisconnectsCounter.Add(1, new KeyValuePair("reason", reason)); } + /// + /// Records that a retry was attempted in the given area. + /// + /// Area in which the retry was attempted. public void RetryAttempted(string area) { lock (_syncRoot) @@ -283,6 +368,9 @@ public sealed class GatewayMetrics : IDisposable _retryAttemptsCounter.Add(1, new KeyValuePair("area", area)); } + /// + /// Returns a snapshot of all current metric values. + /// public GatewayMetricsSnapshot GetSnapshot() { lock (_syncRoot) @@ -312,6 +400,9 @@ public sealed class GatewayMetrics : IDisposable } } + /// + /// Disposes the underlying OpenTelemetry meter. + /// public void Dispose() { if (_disposed) diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs index 828ec7e..5729b54 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs @@ -2,6 +2,9 @@ using System.Text.Json; namespace MxGateway.Server.Security.Authentication; +/// +/// Executes API key administration commands from the CLI. +/// public sealed class ApiKeyAdminCliRunner( IAuthStoreMigrator migrator, IApiKeyAdminStore adminStore, @@ -13,6 +16,12 @@ public sealed class ApiKeyAdminCliRunner( WriteIndented = true }; + /// + /// Runs an API key administration command and writes the output. + /// + /// API key administration command to execute. + /// Text writer for command output. + /// Token to cancel the asynchronous operation. public async Task RunAsync( ApiKeyAdminCommand command, TextWriter output, diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs index 0384a8f..e280304 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs @@ -2,6 +2,9 @@ namespace MxGateway.Server.Security.Authentication; public static class ApiKeyAdminCommandLineParser { + /// Parses command-line arguments for the API key admin subcommand. + /// Command-line arguments to parse. + /// Parse result containing the command kind and options, or a failure message. public static ApiKeyAdminParseResult Parse(IReadOnlyList args) { if (args.Count == 0 || !string.Equals(args[0], "apikey", StringComparison.OrdinalIgnoreCase)) diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminParseResult.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminParseResult.cs index a9a25f0..f4955e6 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminParseResult.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminParseResult.cs @@ -5,16 +5,21 @@ public sealed record ApiKeyAdminParseResult( ApiKeyAdminCommand? Command, string? Error) { + /// Returns a result indicating the input was not an API key command. public static ApiKeyAdminParseResult NotApiKeyCommand() { return new ApiKeyAdminParseResult(false, null, null); } + /// Returns a successful parse result with the parsed API key command. + /// Parsed API key administration command. public static ApiKeyAdminParseResult Success(ApiKeyAdminCommand command) { return new ApiKeyAdminParseResult(true, command, null); } + /// Returns a parse result with the specified error message. + /// Error message describing the parse failure. public static ApiKeyAdminParseResult Fail(string error) { return new ApiKeyAdminParseResult(true, null, error); diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyParser.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyParser.cs index e02a56a..80c3c55 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyParser.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyParser.cs @@ -5,6 +5,10 @@ public sealed class ApiKeyParser : IApiKeyParser private const string BearerPrefix = "Bearer "; private const string TokenPrefix = "mxgw_"; + /// Attempts to parse a Bearer token from an Authorization header and extract the API key ID and secret. + /// Authorization header value to parse. + /// Parsed API key with ID and secret, or null if parsing failed. + /// True if the header was successfully parsed; otherwise, false. public bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey) { apiKey = null; diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs index 9ed9cc7..4363d29 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs @@ -2,8 +2,12 @@ using Microsoft.Data.Sqlite; namespace MxGateway.Server.Security.Authentication; +/// Reads API key records from SQLite query results. public static class ApiKeyRecordReader { + /// Deserializes a row from the API key table into an ApiKeyRecord. + /// The data reader positioned at the API key row. + /// The deserialized API key record. public static ApiKeyRecord Read(SqliteDataReader reader) { return new ApiKeyRecord( diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyScopeSerializer.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyScopeSerializer.cs index 937d419..cc23d95 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyScopeSerializer.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyScopeSerializer.cs @@ -4,11 +4,17 @@ namespace MxGateway.Server.Security.Authentication; public static class ApiKeyScopeSerializer { + /// Serializes scopes to JSON string. + /// The scopes to serialize. + /// JSON string representation. public static string Serialize(IReadOnlySet scopes) { return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal)); } + /// Deserializes scopes from JSON string. + /// The JSON string to deserialize. + /// Deserialized scopes set. public static IReadOnlySet Deserialize(string value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs b/src/MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs index 7aafcf9..c53fa62 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs @@ -2,8 +2,10 @@ using System.Security.Cryptography; namespace MxGateway.Server.Security.Authentication; +/// Generates cryptographically secure API key secrets. public static class ApiKeySecretGenerator { + /// Generates a new random API key secret string. public static string Generate() { Span bytes = stackalloc byte[32]; diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs b/src/MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs index c901035..9734003 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs @@ -9,6 +9,9 @@ public sealed class ApiKeySecretHasher( IConfiguration configuration, IOptions options) : IApiKeySecretHasher { + /// Hashes an API key secret with pepper using HMAC-SHA256. + /// The secret to hash. + /// The hashed secret. public byte[] HashSecret(string secret) { string pepper = GetPepper(); diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs index 385d0a3..7b8a8e0 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs @@ -5,6 +5,11 @@ public sealed record ApiKeyVerificationResult( ApiKeyIdentity? Identity, ApiKeyVerificationFailure Failure) { + /// + /// Creates a successful verification result. + /// + /// API key identity. + /// Success result. public static ApiKeyVerificationResult Success(ApiKeyIdentity identity) { return new ApiKeyVerificationResult( @@ -13,6 +18,11 @@ public sealed record ApiKeyVerificationResult( Failure: ApiKeyVerificationFailure.None); } + /// + /// Creates a failed verification result. + /// + /// Verification failure reason. + /// Failure result. public static ApiKeyVerificationResult Fail(ApiKeyVerificationFailure failure) { return new ApiKeyVerificationResult( diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs index a6354ec..c4a5677 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs @@ -7,6 +7,12 @@ public sealed class ApiKeyVerifier( IApiKeySecretHasher hasher, IApiKeyStore keyStore) : IApiKeyVerifier { + /// + /// Verifies an API key from an authorization header asynchronously. + /// + /// Authorization header value. + /// Cancellation token. + /// Verification result. public async Task VerifyAsync( string? authorizationHeader, CancellationToken cancellationToken) diff --git a/src/MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs b/src/MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs index dbcf423..84a3d43 100644 --- a/src/MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs +++ b/src/MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs @@ -4,8 +4,14 @@ using MxGateway.Server.Configuration; namespace MxGateway.Server.Security.Authentication; +/// +/// Factory for creating SQLite connections to the authentication store. +/// public sealed class AuthSqliteConnectionFactory(IOptions options) { + /// + /// Creates and configures a SQLite connection to the auth database. + /// public SqliteConnection CreateConnection() { string sqlitePath = options.Value.Authentication.SqlitePath; diff --git a/src/MxGateway.Server/Security/Authentication/AuthStoreMigrationHostedService.cs b/src/MxGateway.Server/Security/Authentication/AuthStoreMigrationHostedService.cs index 72fed27..76af995 100644 --- a/src/MxGateway.Server/Security/Authentication/AuthStoreMigrationHostedService.cs +++ b/src/MxGateway.Server/Security/Authentication/AuthStoreMigrationHostedService.cs @@ -3,10 +3,14 @@ using MxGateway.Server.Configuration; namespace MxGateway.Server.Security.Authentication; +/// +/// Hosted service that runs authentication store migrations on startup. +/// public sealed class AuthStoreMigrationHostedService( IOptions options, IAuthStoreMigrator migrator) : IHostedService { + /// public async Task StartAsync(CancellationToken cancellationToken) { AuthenticationOptions authentication = options.Value.Authentication; @@ -17,6 +21,7 @@ public sealed class AuthStoreMigrationHostedService( } } + /// public Task StopAsync(CancellationToken cancellationToken) { return Task.CompletedTask; diff --git a/src/MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs b/src/MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs index 0e142c3..ee46274 100644 --- a/src/MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs +++ b/src/MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs @@ -1,7 +1,15 @@ namespace MxGateway.Server.Security.Authentication; +/// +/// Extension methods for configuring the SQLite authentication store. +/// public static class AuthStoreServiceCollectionExtensions { + /// + /// Adds the SQLite authentication store and related services to the dependency container. + /// + /// Service collection to configure. + /// The service collection for chaining. public static IServiceCollection AddSqliteAuthStore(this IServiceCollection services) { services.AddSingleton(); diff --git a/src/MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs b/src/MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs index ba5ac04..a308283 100644 --- a/src/MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs +++ b/src/MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs @@ -2,12 +2,38 @@ namespace MxGateway.Server.Security.Authentication; public interface IApiKeyAdminStore { + /// + /// Creates a new API key asynchronously. + /// + /// API key creation request. + /// Cancellation token. + /// Completed task. Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken); + /// + /// Lists all API keys asynchronously. + /// + /// Cancellation token. + /// List of API key records. Task> ListAsync(CancellationToken cancellationToken); + /// + /// Revokes an API key asynchronously. + /// + /// Key identifier. + /// Revocation timestamp. + /// Cancellation token. + /// True if revoked; otherwise false. Task RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken); + /// + /// Rotates an API key secret asynchronously. + /// + /// Key identifier. + /// New secret hash. + /// Rotation timestamp. + /// Cancellation token. + /// True if rotated; otherwise false. Task RotateAsync( string keyId, byte[] secretHash, diff --git a/src/MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs b/src/MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs index 1838919..588081a 100644 --- a/src/MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs +++ b/src/MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs @@ -1,8 +1,23 @@ namespace MxGateway.Server.Security.Authentication; +/// +/// Stores and retrieves audit events for API key operations. +/// public interface IApiKeyAuditStore { + /// + /// Appends an audit entry to the audit log. + /// + /// Audit entry to record. + /// Token to cancel the asynchronous operation. + /// Asynchronous task representing the append operation. Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken); + /// + /// Lists the most recent audit entries, up to the specified count. + /// + /// Maximum number of entries to return. + /// Token to cancel the asynchronous operation. + /// Asynchronous task returning the list of audit records. Task> ListRecentAsync(int count, CancellationToken cancellationToken); } diff --git a/src/MxGateway.Server/Security/Authentication/IApiKeyParser.cs b/src/MxGateway.Server/Security/Authentication/IApiKeyParser.cs index 186eb41..f790a02 100644 --- a/src/MxGateway.Server/Security/Authentication/IApiKeyParser.cs +++ b/src/MxGateway.Server/Security/Authentication/IApiKeyParser.cs @@ -2,5 +2,8 @@ namespace MxGateway.Server.Security.Authentication; public interface IApiKeyParser { + /// Attempts to parse an authorization header and extract the API key. + /// Authorization header value to parse. + /// Parsed API key if successful. bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey); } diff --git a/src/MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs b/src/MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs index 04febe8..a6652ae 100644 --- a/src/MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs +++ b/src/MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs @@ -2,5 +2,7 @@ namespace MxGateway.Server.Security.Authentication; public interface IApiKeySecretHasher { + /// Hashes an API key secret and returns the hash bytes. + /// API key secret to hash. byte[] HashSecret(string secret); } diff --git a/src/MxGateway.Server/Security/Authentication/IApiKeyStore.cs b/src/MxGateway.Server/Security/Authentication/IApiKeyStore.cs index d7ab354..a028157 100644 --- a/src/MxGateway.Server/Security/Authentication/IApiKeyStore.cs +++ b/src/MxGateway.Server/Security/Authentication/IApiKeyStore.cs @@ -1,10 +1,21 @@ namespace MxGateway.Server.Security.Authentication; +/// Persists API keys and audit records for authentication and accounting. public interface IApiKeyStore { + /// Retrieves an API key by ID regardless of revocation status. + /// Identifier of the API key. + /// Token to cancel the asynchronous operation. Task FindByKeyIdAsync(string keyId, CancellationToken cancellationToken); + /// Retrieves an active (non-revoked) API key by ID. + /// Identifier of the API key. + /// Token to cancel the asynchronous operation. Task FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken); + /// Records that an API key was used for auditing and tracking. + /// Identifier of the API key. + /// Timestamp when the key was used in UTC. + /// Token to cancel the asynchronous operation. Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken); } diff --git a/src/MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs b/src/MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs index 2b04e08..438650c 100644 --- a/src/MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs +++ b/src/MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs @@ -1,7 +1,11 @@ namespace MxGateway.Server.Security.Authentication; +/// Verifies API key authorization headers and returns the authenticated identity. public interface IApiKeyVerifier { + /// Parses and verifies an authorization header, returning success with identity or a failure reason. + /// The authorization header value to verify. + /// Token to cancel the asynchronous operation. Task VerifyAsync( string? authorizationHeader, CancellationToken cancellationToken); diff --git a/src/MxGateway.Server/Security/Authentication/IAuthStoreMigrator.cs b/src/MxGateway.Server/Security/Authentication/IAuthStoreMigrator.cs index f2994a1..52c5d64 100644 --- a/src/MxGateway.Server/Security/Authentication/IAuthStoreMigrator.cs +++ b/src/MxGateway.Server/Security/Authentication/IAuthStoreMigrator.cs @@ -1,6 +1,10 @@ namespace MxGateway.Server.Security.Authentication; +/// Migrates authentication storage between versions. public interface IAuthStoreMigrator { + /// Performs authentication store migration asynchronously. + /// Token to cancel the asynchronous operation. + /// Asynchronous task representing the migration operation. Task MigrateAsync(CancellationToken cancellationToken); } diff --git a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs b/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs index a5893f4..311090b 100644 --- a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs +++ b/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs @@ -2,8 +2,12 @@ using Microsoft.Data.Sqlite; namespace MxGateway.Server.Security.Authentication; +/// +/// SQLite-backed storage for API key administration (create, list, revoke, rotate). +/// public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore { + /// public async Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken) { await using SqliteConnection connection = connectionFactory.CreateConnection(); @@ -35,6 +39,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } + /// public async Task> ListAsync(CancellationToken cancellationToken) { await using SqliteConnection connection = connectionFactory.CreateConnection(); @@ -60,6 +65,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio return records; } + /// public async Task RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken) { await using SqliteConnection connection = connectionFactory.CreateConnection(); @@ -79,6 +85,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio return rows > 0; } + /// public async Task RotateAsync( string keyId, byte[] secretHash, diff --git a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAuditStore.cs b/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAuditStore.cs index 618920c..8e30aba 100644 --- a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAuditStore.cs +++ b/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAuditStore.cs @@ -4,6 +4,7 @@ namespace MxGateway.Server.Security.Authentication; public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAuditStore { + /// public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken) { await using SqliteConnection connection = connectionFactory.CreateConnection(); @@ -23,6 +24,7 @@ public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectio await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } + /// public async Task> ListRecentAsync(int count, CancellationToken cancellationToken) { if (count <= 0) diff --git a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs b/src/MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs index 414c2b4..c828f52 100644 --- a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs +++ b/src/MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs @@ -2,18 +2,22 @@ using Microsoft.Data.Sqlite; namespace MxGateway.Server.Security.Authentication; +/// SQLite-based store for API key records. public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStore { + /// public Task FindByKeyIdAsync(string keyId, CancellationToken cancellationToken) { return FindByKeyIdAsync(keyId, requireActive: false, cancellationToken); } + /// public Task FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken) { return FindByKeyIdAsync(keyId, requireActive: true, cancellationToken); } + /// public async Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken) { await using SqliteConnection connection = connectionFactory.CreateConnection(); diff --git a/src/MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs b/src/MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs index cb6bb13..9431b9f 100644 --- a/src/MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs +++ b/src/MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs @@ -4,6 +4,8 @@ namespace MxGateway.Server.Security.Authentication; public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory) : IAuthStoreMigrator { + /// Applies database migrations to the authentication store. + /// Cancellation token. public async Task MigrateAsync(CancellationToken cancellationToken) { await using SqliteConnection connection = connectionFactory.CreateConnection(); diff --git a/src/MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs b/src/MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs index b2beb7f..5f95f12 100644 --- a/src/MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs +++ b/src/MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs @@ -12,6 +12,7 @@ public sealed class GatewayGrpcAuthorizationInterceptor( IGatewayRequestIdentityAccessor identityAccessor, IOptions options) : Interceptor { + /// public override async Task UnaryServerHandler( TRequest request, ServerCallContext context, @@ -25,6 +26,7 @@ public sealed class GatewayGrpcAuthorizationInterceptor( } } + /// public override async Task ServerStreamingServerHandler( TRequest request, IServerStreamWriter responseStream, @@ -39,6 +41,11 @@ public sealed class GatewayGrpcAuthorizationInterceptor( } } + /// Authenticates the API key and authorizes the RPC call by required scope. + /// Request message type. + /// Request payload. + /// RPC server call context. + /// Authenticated API key identity, or null if authentication is disabled. private async Task AuthenticateAndAuthorizeAsync( TRequest request, ServerCallContext context) diff --git a/src/MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs b/src/MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs index f5488f6..6c44e48 100644 --- a/src/MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs +++ b/src/MxGateway.Server/Security/Authorization/GatewayGrpcScopeResolver.cs @@ -5,6 +5,11 @@ namespace MxGateway.Server.Security.Authorization; public sealed class GatewayGrpcScopeResolver { + /// + /// Resolves the required authorization scope for a gRPC request. + /// + /// The gRPC request. + /// Required authorization scope. public string ResolveRequiredScope(object request) { return request switch diff --git a/src/MxGateway.Server/Security/Authorization/GatewayRequestIdentityAccessor.cs b/src/MxGateway.Server/Security/Authorization/GatewayRequestIdentityAccessor.cs index 8b9759f..8388446 100644 --- a/src/MxGateway.Server/Security/Authorization/GatewayRequestIdentityAccessor.cs +++ b/src/MxGateway.Server/Security/Authorization/GatewayRequestIdentityAccessor.cs @@ -6,8 +6,12 @@ public sealed class GatewayRequestIdentityAccessor : IGatewayRequestIdentityAcce { private readonly AsyncLocal currentIdentity = new(); + /// Gets the current request identity. public ApiKeyIdentity? Current => currentIdentity.Value; + /// Sets the current identity and returns a scope that restores the previous identity. + /// The identity to push. + /// Disposable scope. public IDisposable Push(ApiKeyIdentity identity) { ArgumentNullException.ThrowIfNull(identity); @@ -24,6 +28,7 @@ public sealed class GatewayRequestIdentityAccessor : IGatewayRequestIdentityAcce { private bool disposed; + /// Restores the previous identity. public void Dispose() { if (disposed) diff --git a/src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs b/src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs index 6de9d56..f7c9398 100644 --- a/src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs +++ b/src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs @@ -2,8 +2,15 @@ using Grpc.Core.Interceptors; namespace MxGateway.Server.Security.Authorization; +/// +/// Extension methods for configuring gRPC authorization services. +/// public static class GrpcAuthorizationServiceCollectionExtensions { + /// + /// Registers gRPC authorization middleware and scope resolver. + /// + /// Service collection to register dependencies into. public static IServiceCollection AddGatewayGrpcAuthorization(this IServiceCollection services) { services.AddSingleton(); diff --git a/src/MxGateway.Server/Security/Authorization/IGatewayRequestIdentityAccessor.cs b/src/MxGateway.Server/Security/Authorization/IGatewayRequestIdentityAccessor.cs index eb87699..bdbee5e 100644 --- a/src/MxGateway.Server/Security/Authorization/IGatewayRequestIdentityAccessor.cs +++ b/src/MxGateway.Server/Security/Authorization/IGatewayRequestIdentityAccessor.cs @@ -2,9 +2,13 @@ using MxGateway.Server.Security.Authentication; namespace MxGateway.Server.Security.Authorization; +/// Provides scoped access to the current request's API key identity within a gRPC call context. public interface IGatewayRequestIdentityAccessor { + /// The API key identity of the current request, or null if not set. ApiKeyIdentity? Current { get; } + /// Temporarily pushes an identity onto the scope stack, returning a handle to restore the previous state. + /// API key identity to push. IDisposable Push(ApiKeyIdentity identity); } diff --git a/src/MxGateway.Server/Sessions/GatewaySession.cs b/src/MxGateway.Server/Sessions/GatewaySession.cs index b0a4427..7fe4d7b 100644 --- a/src/MxGateway.Server/Sessions/GatewaySession.cs +++ b/src/MxGateway.Server/Sessions/GatewaySession.cs @@ -15,6 +15,20 @@ public sealed class GatewaySession private bool _closeStarted; private int _activeEventSubscriberCount; + /// + /// Initializes a gateway session with session metadata and timeout configuration. + /// + /// Identifier of the session. + /// Name of the backend MXAccess proxy server. + /// Name of the named pipe for gateway-worker IPC. + /// Security nonce for worker validation. + /// Client identity from the authentication context. + /// Client-supplied session name. + /// Client-supplied correlation identifier. + /// Timeout for command invocation. + /// Timeout for worker process startup. + /// Timeout for worker process shutdown. + /// Timestamp when the session opened. public GatewaySession( string sessionId, string backendName, @@ -62,32 +76,74 @@ public sealed class GatewaySession _lastClientActivityAt = openedAt; } + /// + /// Gets the session identifier. + /// public string SessionId { get; } + /// + /// Gets the backend MXAccess proxy server name. + /// public string BackendName { get; } + /// + /// Gets the named pipe name for gateway-worker IPC. + /// public string PipeName { get; } + /// + /// Gets the security nonce for worker validation. + /// public string Nonce { get; } + /// + /// Gets the client identity from the authentication context. + /// public string? ClientIdentity { get; } + /// + /// Gets the client-supplied session name. + /// public string? ClientSessionName { get; } + /// + /// Gets the client-supplied correlation identifier. + /// public string? ClientCorrelationId { get; } + /// + /// Gets the command invocation timeout. + /// public TimeSpan CommandTimeout { get; } + /// + /// Gets the worker process startup timeout. + /// public TimeSpan StartupTimeout { get; } + /// + /// Gets the worker process shutdown timeout. + /// public TimeSpan ShutdownTimeout { get; } + /// + /// Gets the timestamp when the session opened. + /// public DateTimeOffset OpenedAt { get; } + /// + /// Gets the worker process identifier, or null if not yet attached. + /// public int? WorkerProcessId => _workerClient?.ProcessId; + /// + /// Gets the attached worker client, or null if not yet attached. + /// public IWorkerClient? WorkerClient => _workerClient; + /// + /// Gets the current session state. + /// public SessionState State { get @@ -99,6 +155,9 @@ public sealed class GatewaySession } } + /// + /// Gets the timestamp of the most recent client activity. + /// public DateTimeOffset LastClientActivityAt { get @@ -110,6 +169,9 @@ public sealed class GatewaySession } } + /// + /// Gets the lease expiration timestamp, or null if no lease is active. + /// public DateTimeOffset? LeaseExpiresAt { get @@ -121,6 +183,9 @@ public sealed class GatewaySession } } + /// + /// Gets the fault description if the session is faulted, or null. + /// public string? FinalFault { get @@ -132,6 +197,9 @@ public sealed class GatewaySession } } + /// + /// Gets the count of active event stream subscribers. + /// public int ActiveEventSubscriberCount { get @@ -143,6 +211,10 @@ public sealed class GatewaySession } } + /// + /// Attaches the worker client for this session. + /// + /// Worker client to attach. public void AttachWorkerClient(IWorkerClient workerClient) { ArgumentNullException.ThrowIfNull(workerClient); @@ -153,6 +225,10 @@ public sealed class GatewaySession } } + /// + /// Transitions the session to a new state with constraints for terminal states. + /// + /// Next session state to transition to. public void TransitionTo(SessionState nextState) { lock (_syncRoot) @@ -171,11 +247,18 @@ public sealed class GatewaySession } } + /// + /// Transitions the session to the Ready state. + /// public void MarkReady() { TransitionTo(SessionState.Ready); } + /// + /// Transitions the session to the Faulted state with a fault description. + /// + /// Reason for the fault. public void MarkFaulted(string reason) { lock (_syncRoot) @@ -190,6 +273,10 @@ public sealed class GatewaySession } } + /// + /// Updates the timestamp of the most recent client activity. + /// + /// Timestamp of the client activity. public void TouchClientActivity(DateTimeOffset activityAt) { lock (_syncRoot) @@ -198,6 +285,10 @@ public sealed class GatewaySession } } + /// + /// Extends the session lease to the specified expiration time. + /// + /// Timestamp when the lease expires. public void ExtendLease(DateTimeOffset leaseExpiresAt) { lock (_syncRoot) @@ -206,6 +297,10 @@ public sealed class GatewaySession } } + /// + /// Determines whether the session lease has expired. + /// + /// Current timestamp for comparison. public bool IsLeaseExpired(DateTimeOffset now) { lock (_syncRoot) @@ -214,6 +309,10 @@ public sealed class GatewaySession } } + /// + /// Attaches an event subscriber and returns a disposable lease. + /// + /// If true, allows multiple concurrent event subscribers. public IDisposable AttachEventSubscriber(bool allowMultipleSubscribers) { lock (_syncRoot) @@ -237,6 +336,11 @@ public sealed class GatewaySession } } + /// + /// Invokes a worker command synchronously and returns the reply. + /// + /// Worker command to invoke. + /// Token to cancel the asynchronous operation. public async Task InvokeAsync( WorkerCommand command, CancellationToken cancellationToken) @@ -247,6 +351,12 @@ public sealed class GatewaySession return await workerClient.InvokeAsync(command, CommandTimeout, cancellationToken).ConfigureAwait(false); } + /// + /// Executes a bulk add-item command for the specified server and tag addresses. + /// + /// Server handle returned by the worker. + /// Tag addresses to add. + /// Token to cancel the asynchronous operation. public Task> AddItemBulkAsync( int serverHandle, IReadOnlyList tagAddresses, @@ -266,6 +376,12 @@ public sealed class GatewaySession cancellationToken); } + /// + /// Executes a bulk advise-item command for the specified server and item handles. + /// + /// Server handle returned by the worker. + /// Item handles to advise. + /// Token to cancel the asynchronous operation. public Task> AdviseItemBulkAsync( int serverHandle, IReadOnlyList itemHandles, @@ -285,6 +401,12 @@ public sealed class GatewaySession cancellationToken); } + /// + /// Executes a bulk remove-item command for the specified server and item handles. + /// + /// Server handle returned by the worker. + /// Item handles to remove. + /// Token to cancel the asynchronous operation. public Task> RemoveItemBulkAsync( int serverHandle, IReadOnlyList itemHandles, @@ -304,6 +426,12 @@ public sealed class GatewaySession cancellationToken); } + /// + /// Executes a bulk un-advise-item command for the specified server and item handles. + /// + /// Server handle returned by the worker. + /// Item handles to un-advise. + /// Token to cancel the asynchronous operation. public Task> UnAdviseItemBulkAsync( int serverHandle, IReadOnlyList itemHandles, @@ -323,6 +451,12 @@ public sealed class GatewaySession cancellationToken); } + /// + /// Executes a bulk subscribe command for the specified server and tag addresses. + /// + /// Server handle returned by the worker. + /// Tag addresses to subscribe to. + /// Token to cancel the asynchronous operation. public Task> SubscribeBulkAsync( int serverHandle, IReadOnlyList tagAddresses, @@ -342,6 +476,12 @@ public sealed class GatewaySession cancellationToken); } + /// + /// Executes a bulk unsubscribe command for the specified server and item handles. + /// + /// Server handle returned by the worker. + /// Item handles to unsubscribe from. + /// Token to cancel the asynchronous operation. public Task> UnsubscribeBulkAsync( int serverHandle, IReadOnlyList itemHandles, @@ -361,6 +501,10 @@ public sealed class GatewaySession cancellationToken); } + /// + /// Reads events from the worker as an asynchronous enumerable stream. + /// + /// Token to cancel the asynchronous operation. public IAsyncEnumerable ReadEventsAsync(CancellationToken cancellationToken) { IWorkerClient workerClient = GetReadyWorkerClient(); @@ -369,6 +513,11 @@ public sealed class GatewaySession return workerClient.ReadEventsAsync(cancellationToken); } + /// + /// Closes the session and shuts down the worker process. + /// + /// Reason for closing the session. + /// Token to cancel the asynchronous operation. public async Task CloseAsync( string reason, CancellationToken cancellationToken) @@ -426,12 +575,19 @@ public sealed class GatewaySession } } + /// + /// Terminates the worker process immediately. + /// + /// Reason for killing the worker. public void KillWorker(string reason) { _workerClient?.Kill(reason); TransitionTo(SessionState.Closed); } + /// + /// Disposes the session and frees associated resources. + /// public async ValueTask DisposeAsync() { _closeLock.Dispose(); @@ -500,6 +656,9 @@ public sealed class GatewaySession { private bool _disposed; + /// + /// Disposes the lease and detaches the event subscriber. + /// public void Dispose() { if (_disposed) diff --git a/src/MxGateway.Server/Sessions/ISessionManager.cs b/src/MxGateway.Server/Sessions/ISessionManager.cs index 18de3f4..d9743f5 100644 --- a/src/MxGateway.Server/Sessions/ISessionManager.cs +++ b/src/MxGateway.Server/Sessions/ISessionManager.cs @@ -4,31 +4,59 @@ namespace MxGateway.Server.Sessions; public interface ISessionManager { + /// Opens a new gateway session and launches a worker process. + /// Request payload. + /// Client identity string. + /// Token to cancel the asynchronous operation. + /// The newly opened session. Task OpenSessionAsync( SessionOpenRequest request, string? clientIdentity, CancellationToken cancellationToken); + /// Attempts to retrieve a session by ID. + /// Identifier of the session. + /// The retrieved session, if found. + /// True if the session exists; otherwise false. bool TryGetSession( string sessionId, out GatewaySession session); + /// Invokes a command on the worker for the specified session. + /// Identifier of the session. + /// Command to invoke. + /// Token to cancel the asynchronous operation. + /// The command reply from the worker. Task InvokeAsync( string sessionId, WorkerCommand command, CancellationToken cancellationToken); + /// Reads events streamed from the worker for the specified session. + /// Identifier of the session. + /// Token to cancel the asynchronous operation. + /// Events emitted by the worker. IAsyncEnumerable ReadEventsAsync( string sessionId, CancellationToken cancellationToken); + /// Closes a session and terminates its worker process. + /// Identifier of the session to close. + /// Token to cancel the asynchronous operation. + /// The result of closing the session. Task CloseSessionAsync( string sessionId, CancellationToken cancellationToken); + /// Closes all sessions with expired leases at the specified time. + /// The current time to evaluate expiration against. + /// Token to cancel the asynchronous operation. + /// The number of sessions closed. Task CloseExpiredLeasesAsync( DateTimeOffset now, CancellationToken cancellationToken); + /// Shuts down all sessions and the session manager. + /// Token to cancel the asynchronous operation. Task ShutdownAsync(CancellationToken cancellationToken); } diff --git a/src/MxGateway.Server/Sessions/ISessionRegistry.cs b/src/MxGateway.Server/Sessions/ISessionRegistry.cs index 9f66f83..294e03a 100644 --- a/src/MxGateway.Server/Sessions/ISessionRegistry.cs +++ b/src/MxGateway.Server/Sessions/ISessionRegistry.cs @@ -1,16 +1,46 @@ namespace MxGateway.Server.Sessions; +/// +/// Registry for managing active gateway sessions. +/// public interface ISessionRegistry { + /// + /// Total number of sessions (open or closed) in the registry. + /// int Count { get; } + /// + /// Number of sessions currently in an active (open) state. + /// int ActiveCount { get; } + /// + /// Attempts to add a session to the registry; returns false if session ID already exists. + /// + /// Session to add. + /// True if added; false if session ID already exists. bool TryAdd(GatewaySession session); + /// + /// Attempts to retrieve a session by ID; returns false if not found. + /// + /// Identifier of the session. + /// The retrieved session, if found. + /// True if found; false otherwise. bool TryGet(string sessionId, out GatewaySession session); + /// + /// Attempts to remove a session by ID; returns false if not found. + /// + /// Identifier of the session to remove. + /// The removed session, if found. + /// True if removed; false if not found. bool TryRemove(string sessionId, out GatewaySession session); + /// + /// Returns a snapshot of all sessions in the registry. + /// + /// A read-only collection of all sessions. IReadOnlyCollection Snapshot(); } diff --git a/src/MxGateway.Server/Sessions/ISessionWorkerClientFactory.cs b/src/MxGateway.Server/Sessions/ISessionWorkerClientFactory.cs index 8268dbf..b47eada 100644 --- a/src/MxGateway.Server/Sessions/ISessionWorkerClientFactory.cs +++ b/src/MxGateway.Server/Sessions/ISessionWorkerClientFactory.cs @@ -1,7 +1,16 @@ namespace MxGateway.Server.Sessions; +/// +/// Creates worker client instances for gateway sessions. +/// public interface ISessionWorkerClientFactory { + /// + /// Creates a worker client for the specified session. + /// + /// Session to create a worker client for. + /// Token to cancel the asynchronous operation. + /// A worker client connected to the worker process. Task CreateAsync( GatewaySession session, CancellationToken cancellationToken); diff --git a/src/MxGateway.Server/Sessions/SessionCloseStartedException.cs b/src/MxGateway.Server/Sessions/SessionCloseStartedException.cs index cb46247..77b7e58 100644 --- a/src/MxGateway.Server/Sessions/SessionCloseStartedException.cs +++ b/src/MxGateway.Server/Sessions/SessionCloseStartedException.cs @@ -2,6 +2,9 @@ namespace MxGateway.Server.Sessions; internal sealed class SessionCloseStartedException : Exception { + /// Initializes a new instance of the class. + /// The exception message. + /// The exception that caused this error. public SessionCloseStartedException( string message, Exception innerException) diff --git a/src/MxGateway.Server/Sessions/SessionManager.cs b/src/MxGateway.Server/Sessions/SessionManager.cs index 96a2715..193bac4 100644 --- a/src/MxGateway.Server/Sessions/SessionManager.cs +++ b/src/MxGateway.Server/Sessions/SessionManager.cs @@ -25,6 +25,15 @@ public sealed class SessionManager : ISessionManager private readonly GatewayOptions _options; private readonly SemaphoreSlim _sessionSlots; + /// + /// Initializes a new instance of . + /// + /// Session registry. + /// Worker client factory. + /// Gateway options. + /// Gateway metrics. + /// Time provider for timestamps. + /// Logger. public SessionManager( ISessionRegistry registry, ISessionWorkerClientFactory workerClientFactory, @@ -43,6 +52,13 @@ public sealed class SessionManager : ISessionManager _sessionSlots = new SemaphoreSlim(_options.Sessions.MaxSessions, _options.Sessions.MaxSessions); } + /// + /// Opens a new gateway session and connects to the worker. + /// + /// Session open request. + /// Client authentication identity. + /// Cancellation token. + /// Opened gateway session. public async Task OpenSessionAsync( SessionOpenRequest request, string? clientIdentity, @@ -96,6 +112,12 @@ public sealed class SessionManager : ISessionManager } } + /// + /// Attempts to retrieve a session by ID. + /// + /// Session identifier. + /// The session if found. + /// True if session found; otherwise false. public bool TryGetSession( string sessionId, out GatewaySession session) @@ -103,6 +125,13 @@ public sealed class SessionManager : ISessionManager return _registry.TryGet(sessionId, out session); } + /// + /// Invokes a worker command on a session asynchronously. + /// + /// Session identifier. + /// Worker command. + /// Cancellation token. + /// Command reply. public async Task InvokeAsync( string sessionId, WorkerCommand command, @@ -129,6 +158,12 @@ public sealed class SessionManager : ISessionManager } } + /// + /// Reads events from a session's event stream asynchronously. + /// + /// Session identifier. + /// Cancellation token. + /// Async enumerable of worker events. public IAsyncEnumerable ReadEventsAsync( string sessionId, CancellationToken cancellationToken) @@ -138,6 +173,12 @@ public sealed class SessionManager : ISessionManager return session.ReadEventsAsync(cancellationToken); } + /// + /// Closes a gateway session asynchronously. + /// + /// Session identifier. + /// Cancellation token. + /// Session close result. public async Task CloseSessionAsync( string sessionId, CancellationToken cancellationToken) @@ -151,6 +192,12 @@ public sealed class SessionManager : ISessionManager return result; } + /// + /// Closes all sessions with expired leases asynchronously. + /// + /// Current time for lease expiration check. + /// Cancellation token. + /// Count of sessions closed. public async Task CloseExpiredLeasesAsync( DateTimeOffset now, CancellationToken cancellationToken) @@ -170,6 +217,11 @@ public sealed class SessionManager : ISessionManager return closedCount; } + /// + /// Shuts down all active sessions gracefully asynchronously. + /// + /// Cancellation token. + /// Completed task. public async Task ShutdownAsync(CancellationToken cancellationToken) { foreach (GatewaySession session in _registry.Snapshot()) diff --git a/src/MxGateway.Server/Sessions/SessionManagerException.cs b/src/MxGateway.Server/Sessions/SessionManagerException.cs index 9a87327..1bbb077 100644 --- a/src/MxGateway.Server/Sessions/SessionManagerException.cs +++ b/src/MxGateway.Server/Sessions/SessionManagerException.cs @@ -2,6 +2,11 @@ namespace MxGateway.Server.Sessions; public sealed class SessionManagerException : Exception { + /// + /// Initializes a new instance of . + /// + /// Session manager error code. + /// Exception message. public SessionManagerException( SessionManagerErrorCode errorCode, string message) @@ -10,6 +15,12 @@ public sealed class SessionManagerException : Exception ErrorCode = errorCode; } + /// + /// Initializes a new instance of with an inner exception. + /// + /// Session manager error code. + /// Exception message. + /// Inner exception. public SessionManagerException( SessionManagerErrorCode errorCode, string message, @@ -19,5 +30,8 @@ public sealed class SessionManagerException : Exception ErrorCode = errorCode; } + /// + /// Gets the session manager error code. + /// public SessionManagerErrorCode ErrorCode { get; } } diff --git a/src/MxGateway.Server/Sessions/SessionOpenRequest.cs b/src/MxGateway.Server/Sessions/SessionOpenRequest.cs index 8006d98..ae62817 100644 --- a/src/MxGateway.Server/Sessions/SessionOpenRequest.cs +++ b/src/MxGateway.Server/Sessions/SessionOpenRequest.cs @@ -9,6 +9,8 @@ public sealed record SessionOpenRequest( string? ClientCorrelationId, Duration? CommandTimeout) { + /// Creates a SessionOpenRequest from a gRPC OpenSessionRequest contract. + /// Request payload. public static SessionOpenRequest FromContract(OpenSessionRequest request) { ArgumentNullException.ThrowIfNull(request); diff --git a/src/MxGateway.Server/Sessions/SessionRegistry.cs b/src/MxGateway.Server/Sessions/SessionRegistry.cs index 4c87f08..fbeb842 100644 --- a/src/MxGateway.Server/Sessions/SessionRegistry.cs +++ b/src/MxGateway.Server/Sessions/SessionRegistry.cs @@ -3,14 +3,27 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Server.Sessions; +/// +/// Thread-safe registry of active gateway sessions. +/// public sealed class SessionRegistry : ISessionRegistry { private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); + /// + /// Gets the total count of sessions in the registry. + /// public int Count => _sessions.Count; + /// + /// Gets the count of non-closed sessions. + /// public int ActiveCount => _sessions.Values.Count(session => session.State is not SessionState.Closed); + /// + /// Adds a session to the registry. + /// + /// Gateway session to add. public bool TryAdd(GatewaySession session) { ArgumentNullException.ThrowIfNull(session); @@ -18,6 +31,11 @@ public sealed class SessionRegistry : ISessionRegistry return _sessions.TryAdd(session.SessionId, session); } + /// + /// Retrieves a session by identifier. + /// + /// Identifier of the session. + /// The retrieved session if found. public bool TryGet( string sessionId, out GatewaySession session) @@ -25,6 +43,11 @@ public sealed class SessionRegistry : ISessionRegistry return _sessions.TryGetValue(sessionId, out session!); } + /// + /// Removes a session from the registry by identifier. + /// + /// Identifier of the session. + /// The removed session if found. public bool TryRemove( string sessionId, out GatewaySession session) @@ -32,6 +55,9 @@ public sealed class SessionRegistry : ISessionRegistry return _sessions.TryRemove(sessionId, out session!); } + /// + /// Returns a snapshot of all sessions in the registry. + /// public IReadOnlyCollection Snapshot() { return _sessions.Values.ToArray(); diff --git a/src/MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs b/src/MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs index 4cdb620..27c3581 100644 --- a/src/MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs +++ b/src/MxGateway.Server/Sessions/SessionServiceCollectionExtensions.cs @@ -1,7 +1,11 @@ namespace MxGateway.Server.Sessions; +/// Service collection extensions for session management. public static class SessionServiceCollectionExtensions { + /// Registers gateway session registry, manager, and factory services. + /// Service collection to register services in. + /// The service collection for chaining. public static IServiceCollection AddGatewaySessions(this IServiceCollection services) { services.AddSingleton(); diff --git a/src/MxGateway.Server/Sessions/SessionShutdownHostedService.cs b/src/MxGateway.Server/Sessions/SessionShutdownHostedService.cs index 9ec0a1f..07f3f14 100644 --- a/src/MxGateway.Server/Sessions/SessionShutdownHostedService.cs +++ b/src/MxGateway.Server/Sessions/SessionShutdownHostedService.cs @@ -3,15 +3,18 @@ using Microsoft.Extensions.Logging; namespace MxGateway.Server.Sessions; +/// Hosted service that cleanly shuts down all gateway sessions on application shutdown. public sealed class SessionShutdownHostedService( ISessionManager sessionManager, ILogger logger) : IHostedService { + /// public Task StartAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } + /// public async Task StopAsync(CancellationToken cancellationToken) { try diff --git a/src/MxGateway.Server/Sessions/SessionWorkerClientFactory.cs b/src/MxGateway.Server/Sessions/SessionWorkerClientFactory.cs index 8b1d0bc..82cc23d 100644 --- a/src/MxGateway.Server/Sessions/SessionWorkerClientFactory.cs +++ b/src/MxGateway.Server/Sessions/SessionWorkerClientFactory.cs @@ -9,6 +9,7 @@ using MxGateway.Server.Workers; namespace MxGateway.Server.Sessions; +/// Factory for creating worker clients and launching worker processes. public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory { private readonly IWorkerProcessLauncher _workerProcessLauncher; @@ -17,6 +18,12 @@ public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory private readonly ILoggerFactory _loggerFactory; private readonly GatewayOptions _options; + /// Initializes a new instance of the SessionWorkerClientFactory class. + /// Service for launching worker processes. + /// Configuration options. + /// Metrics collector for gateway events. + /// Logger factory for creating loggers. + /// Optional time provider for testing; defaults to system time. public SessionWorkerClientFactory( IWorkerProcessLauncher workerProcessLauncher, IOptions options, @@ -32,6 +39,10 @@ public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory _options = options.Value; } + /// Creates a worker client and launches the worker process. + /// The gateway session. + /// Cancellation token. + /// The created worker client. public async Task CreateAsync( GatewaySession session, CancellationToken cancellationToken) @@ -144,6 +155,9 @@ public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory } } + /// Creates a named pipe for worker communication. + /// The pipe name. + /// Named pipe server stream. private static NamedPipeServerStream CreatePipe(string pipeName) { return new NamedPipeServerStream( @@ -154,6 +168,9 @@ public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory PipeOptions.Asynchronous); } + /// Waits for a client to connect to the pipe. + /// The named pipe. + /// Cancellation token. private static async Task WaitForPipeConnectionAsync( NamedPipeServerStream pipe, CancellationToken cancellationToken) diff --git a/src/MxGateway.Server/Workers/IWorkerClient.cs b/src/MxGateway.Server/Workers/IWorkerClient.cs index 7d8c69d..d5f3379 100644 --- a/src/MxGateway.Server/Workers/IWorkerClient.cs +++ b/src/MxGateway.Server/Workers/IWorkerClient.cs @@ -2,26 +2,44 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Server.Workers; +/// Manages communication with a single worker process via a named pipe. public interface IWorkerClient : IAsyncDisposable { + /// Unique session identifier for this worker. string SessionId { get; } + /// Process ID of the worker, or null before handshake completes. int? ProcessId { get; } + /// Current state of the worker connection. WorkerClientState State { get; } + /// UTC timestamp of the most recent heartbeat from the worker. DateTimeOffset LastHeartbeatAt { get; } + /// Initiates the handshake and enters ready state. + /// Token to cancel the asynchronous operation. Task StartAsync(CancellationToken cancellationToken); + /// Sends a command to the worker and waits for a reply. + /// Worker command to invoke. + /// Timeout for waiting for the reply. + /// Token to cancel the asynchronous operation. Task InvokeAsync( WorkerCommand command, TimeSpan timeout, CancellationToken cancellationToken); + /// Reads events from the worker as they arrive. + /// Token to cancel the asynchronous operation. IAsyncEnumerable ReadEventsAsync(CancellationToken cancellationToken); + /// Gracefully shuts down the worker by closing the connection. + /// Timeout for shutdown. + /// Token to cancel the asynchronous operation. Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken); + /// Terminates the worker process immediately with a diagnostic reason. + /// Reason for terminating the worker. void Kill(string reason); } diff --git a/src/MxGateway.Server/Workers/IWorkerProcess.cs b/src/MxGateway.Server/Workers/IWorkerProcess.cs index 0231c49..f9bc378 100644 --- a/src/MxGateway.Server/Workers/IWorkerProcess.cs +++ b/src/MxGateway.Server/Workers/IWorkerProcess.cs @@ -1,14 +1,34 @@ namespace MxGateway.Server.Workers; +/// +/// Abstraction over a worker process with lifecycle and exit-code operations. +/// public interface IWorkerProcess : IDisposable { + /// + /// The process ID. + /// int Id { get; } + /// + /// Indicates whether the process has exited. + /// bool HasExited { get; } + /// + /// The exit code if the process has exited; otherwise null. + /// int? ExitCode { get; } + /// + /// Waits for the process to exit with the specified cancellation token. + /// + /// Token to cancel the asynchronous operation. ValueTask WaitForExitAsync(CancellationToken cancellationToken); + /// + /// Kills the process, optionally terminating the entire process tree. + /// + /// If true, terminate all child processes; otherwise terminate only this process. void Kill(bool entireProcessTree); } diff --git a/src/MxGateway.Server/Workers/IWorkerProcessFactory.cs b/src/MxGateway.Server/Workers/IWorkerProcessFactory.cs index 5741df1..7f85404 100644 --- a/src/MxGateway.Server/Workers/IWorkerProcessFactory.cs +++ b/src/MxGateway.Server/Workers/IWorkerProcessFactory.cs @@ -2,7 +2,11 @@ using System.Diagnostics; namespace MxGateway.Server.Workers; +/// Factory for creating and starting worker processes. public interface IWorkerProcessFactory { + /// Starts a worker process with the specified start information. + /// Process start configuration. + /// The started worker process. IWorkerProcess Start(ProcessStartInfo startInfo); } diff --git a/src/MxGateway.Server/Workers/IWorkerProcessLauncher.cs b/src/MxGateway.Server/Workers/IWorkerProcessLauncher.cs index ccc1036..2b9607c 100644 --- a/src/MxGateway.Server/Workers/IWorkerProcessLauncher.cs +++ b/src/MxGateway.Server/Workers/IWorkerProcessLauncher.cs @@ -2,6 +2,10 @@ namespace MxGateway.Server.Workers; public interface IWorkerProcessLauncher { + /// Launches a new worker process with the specified configuration. + /// The launch request. + /// Cancellation token. + /// The worker process handle. Task LaunchAsync( WorkerProcessLaunchRequest request, CancellationToken cancellationToken = default); diff --git a/src/MxGateway.Server/Workers/IWorkerStartupProbe.cs b/src/MxGateway.Server/Workers/IWorkerStartupProbe.cs index 81245db..9a6b840 100644 --- a/src/MxGateway.Server/Workers/IWorkerStartupProbe.cs +++ b/src/MxGateway.Server/Workers/IWorkerStartupProbe.cs @@ -2,6 +2,13 @@ namespace MxGateway.Server.Workers; public interface IWorkerStartupProbe { + /// + /// Waits for the worker process to reach a ready state asynchronously. + /// + /// Worker process to probe. + /// Worker launch request. + /// Cancellation token. + /// Completed task. Task WaitUntilReadyAsync( IWorkerProcess process, WorkerProcessLaunchRequest request, diff --git a/src/MxGateway.Server/Workers/SystemWorkerProcess.cs b/src/MxGateway.Server/Workers/SystemWorkerProcess.cs index 72adddf..35f4e32 100644 --- a/src/MxGateway.Server/Workers/SystemWorkerProcess.cs +++ b/src/MxGateway.Server/Workers/SystemWorkerProcess.cs @@ -2,24 +2,33 @@ using System.Diagnostics; namespace MxGateway.Server.Workers; +/// +/// Wraps a System.Diagnostics.Process as an IWorkerProcess. +/// internal sealed class SystemWorkerProcess(Process process) : IWorkerProcess { + /// public int Id => process.Id; + /// public bool HasExited => process.HasExited; + /// public int? ExitCode => process.HasExited ? process.ExitCode : null; + /// public async ValueTask WaitForExitAsync(CancellationToken cancellationToken) { await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); } + /// public void Kill(bool entireProcessTree) { process.Kill(entireProcessTree); } + /// public void Dispose() { process.Dispose(); diff --git a/src/MxGateway.Server/Workers/SystemWorkerProcessFactory.cs b/src/MxGateway.Server/Workers/SystemWorkerProcessFactory.cs index bdf7567..509d7bd 100644 --- a/src/MxGateway.Server/Workers/SystemWorkerProcessFactory.cs +++ b/src/MxGateway.Server/Workers/SystemWorkerProcessFactory.cs @@ -2,8 +2,12 @@ using System.Diagnostics; namespace MxGateway.Server.Workers; +/// +/// Factory that creates system processes for workers. +/// public sealed class SystemWorkerProcessFactory : IWorkerProcessFactory { + /// public IWorkerProcess Start(ProcessStartInfo startInfo) { Process process = new() diff --git a/src/MxGateway.Server/Workers/WorkerClient.cs b/src/MxGateway.Server/Workers/WorkerClient.cs index 25ed454..5e9fa9a 100644 --- a/src/MxGateway.Server/Workers/WorkerClient.cs +++ b/src/MxGateway.Server/Workers/WorkerClient.cs @@ -38,6 +38,12 @@ public sealed class WorkerClient : IWorkerClient private bool _workerStartRecorded; private bool _disposed; + /// Initializes a client for communicating with a worker process over a named pipe. + /// Named pipe connection to the worker. + /// Worker client configuration; defaults to WorkerClientOptions if null. + /// Gateway metrics sink; null disables metrics recording. + /// Time provider for timestamps; defaults to system time if null. + /// Logger instance; defaults to NullLogger if null. public WorkerClient( WorkerClientConnection connection, WorkerClientOptions? options = null, @@ -72,8 +78,10 @@ public sealed class WorkerClient : IWorkerClient _lastHeartbeatAt = _timeProvider.GetUtcNow(); } + /// Gets the worker's session ID. public string SessionId => _connection.SessionId; + /// Gets the worker process ID. public int? ProcessId { get @@ -85,6 +93,7 @@ public sealed class WorkerClient : IWorkerClient } } + /// Gets the current client state. public WorkerClientState State { get @@ -96,6 +105,7 @@ public sealed class WorkerClient : IWorkerClient } } + /// Gets the timestamp of the last received heartbeat. public DateTimeOffset LastHeartbeatAt { get @@ -107,6 +117,8 @@ public sealed class WorkerClient : IWorkerClient } } + /// Starts the worker client and completes the handshake. + /// Cancellation token. public async Task StartAsync(CancellationToken cancellationToken) { ThrowIfDisposed(); @@ -129,6 +141,11 @@ public sealed class WorkerClient : IWorkerClient _heartbeatLoopTask = Task.Run(HeartbeatLoopAsync); } + /// Invokes a command on the worker and waits for reply. + /// The command to invoke. + /// Command timeout. + /// Cancellation token. + /// The command reply. public async Task InvokeAsync( WorkerCommand command, TimeSpan timeout, @@ -211,6 +228,9 @@ public sealed class WorkerClient : IWorkerClient } } + /// Reads events from the worker as an async stream. + /// Cancellation token. + /// Async enumerable of worker events. public async IAsyncEnumerable ReadEventsAsync( [EnumeratorCancellation] CancellationToken cancellationToken) { @@ -222,6 +242,9 @@ public sealed class WorkerClient : IWorkerClient } } + /// Shuts down the worker gracefully. + /// Shutdown timeout. + /// Cancellation token. public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) { ThrowIfDisposed(); @@ -260,6 +283,8 @@ public sealed class WorkerClient : IWorkerClient } } + /// Terminates the worker process immediately. + /// Reason for termination. public void Kill(string reason) { ThrowIfDisposed(); @@ -271,6 +296,7 @@ public sealed class WorkerClient : IWorkerClient null); } + /// Disposes the worker client and releases resources. public async ValueTask DisposeAsync() { if (_disposed) @@ -305,6 +331,7 @@ public sealed class WorkerClient : IWorkerClient _stopCts.Dispose(); } + /// Manages writing envelopes to the worker pipe. private async Task WriteLoopAsync() { try @@ -326,6 +353,7 @@ public sealed class WorkerClient : IWorkerClient } } + /// Manages reading envelopes from the worker pipe. private async Task ReadLoopAsync() { try @@ -355,6 +383,7 @@ public sealed class WorkerClient : IWorkerClient } } + /// Monitors worker heartbeat and detects stale sessions. private async Task HeartbeatLoopAsync() { try @@ -386,6 +415,9 @@ public sealed class WorkerClient : IWorkerClient } } + /// Routes received envelope to appropriate handler. + /// The envelope to dispatch. + /// Cancellation token. private async Task DispatchEnvelopeAsync( WorkerEnvelope envelope, CancellationToken cancellationToken) @@ -419,6 +451,9 @@ public sealed class WorkerClient : IWorkerClient } } + /// Enqueues a worker event for client consumption. + /// The event to enqueue. + /// Cancellation token. private async Task EnqueueWorkerEventAsync( WorkerEvent workerEvent, CancellationToken cancellationToken) @@ -442,6 +477,8 @@ public sealed class WorkerClient : IWorkerClient _metrics?.SetWorkerEventQueueDepth(queueDepth); } + /// Completes pending command with worker reply. + /// Envelope containing command reply. private void CompleteCommand(WorkerEnvelope envelope) { string correlationId = envelope.CorrelationId; @@ -465,6 +502,11 @@ public sealed class WorkerClient : IWorkerClient pendingCommand.SetResult(envelope.WorkerCommandReply); } + /// Fails a pending command with an error. + /// Command correlation ID. + /// The pending command. + /// Error code. + /// Error message. private void RemovePendingCommandAsFailed( string correlationId, PendingCommand pendingCommand, @@ -482,6 +524,10 @@ public sealed class WorkerClient : IWorkerClient pendingCommand.SetException(new WorkerClientException(errorCode, message)); } + /// Reads and validates a handshake envelope. + /// Expected envelope body type. + /// Cancellation token. + /// The read envelope. private async Task ReadHandshakeEnvelopeAsync( WorkerEnvelope.BodyOneofCase expectedBody, CancellationToken cancellationToken) @@ -497,6 +543,8 @@ public sealed class WorkerClient : IWorkerClient return envelope; } + /// Validates worker hello message protocol and nonce. + /// The hello message to validate. private void ValidateWorkerHello(WorkerHello workerHello) { if (workerHello.ProtocolVersion != _connection.FrameOptions.ProtocolVersion) @@ -521,6 +569,8 @@ public sealed class WorkerClient : IWorkerClient } } + /// Marks the worker as ready and records startup metrics. + /// The ready message. private void MarkReady(WorkerReady ready) { lock (_syncRoot) @@ -538,6 +588,8 @@ public sealed class WorkerClient : IWorkerClient _metrics?.WorkerStarted(readyAt - launchedAt); } + /// Updates the heartbeat timestamp and process ID. + /// The heartbeat message. private void MarkHeartbeat(WorkerHeartbeat heartbeat) { lock (_syncRoot) @@ -550,6 +602,7 @@ public sealed class WorkerClient : IWorkerClient } } + /// Transitions to closing state. private void MarkClosing() { lock (_syncRoot) @@ -563,6 +616,8 @@ public sealed class WorkerClient : IWorkerClient } } + /// Marks client as closed and cleans up resources. + /// Reason for closure. private void MarkClosed(string reason) { lock (_syncRoot) @@ -585,6 +640,10 @@ public sealed class WorkerClient : IWorkerClient RecordWorkerStoppedOnce(reason); } + /// Marks client as faulted and propagates the error. + /// Error code. + /// Error message. + /// Optional inner exception. private void SetFaulted( WorkerClientErrorCode errorCode, string message, @@ -613,6 +672,8 @@ public sealed class WorkerClient : IWorkerClient _logger.LogWarning(exception, "Worker client faulted for session {SessionId}: {Message}", SessionId, message); } + /// Records worker stopped metric only once. + /// Reason for stopping. private void RecordWorkerStoppedOnce(string reason) { bool shouldRecord; @@ -628,6 +689,8 @@ public sealed class WorkerClient : IWorkerClient } } + /// Fails all pending commands with the given exception. + /// Exception to apply to all commands. private void CompletePendingCommands(Exception exception) { foreach (KeyValuePair item in _pendingCommands.ToArray()) @@ -642,6 +705,7 @@ public sealed class WorkerClient : IWorkerClient } } + /// Releases a pending command slot. private void ReleasePendingCommandSlot() { try @@ -653,6 +717,7 @@ public sealed class WorkerClient : IWorkerClient } } + /// Transitions from created to handshaking state. private void TransitionFromCreatedToHandshaking() { lock (_syncRoot) @@ -668,6 +733,7 @@ public sealed class WorkerClient : IWorkerClient } } + /// Throws if client is not in ready state. private void EnsureReady() { WorkerClientState state = State; @@ -679,12 +745,17 @@ public sealed class WorkerClient : IWorkerClient } } + /// Checks if current state is terminal. + /// True if closed, closing, or faulted. private bool IsTerminalState() { WorkerClientState state = State; return state is WorkerClientState.Closing or WorkerClientState.Closed or WorkerClientState.Faulted; } + /// Enqueues an envelope for writing to the worker. + /// Envelope to enqueue. + /// Cancellation token. private async Task EnqueueAsync( WorkerEnvelope envelope, CancellationToken cancellationToken) @@ -702,6 +773,8 @@ public sealed class WorkerClient : IWorkerClient } } + /// Creates gateway hello envelope. + /// The hello envelope. private WorkerEnvelope CreateGatewayHelloEnvelope() { return CreateEnvelope( @@ -714,6 +787,10 @@ public sealed class WorkerClient : IWorkerClient }); } + /// Creates command envelope. + /// Command correlation ID. + /// The command to wrap. + /// The command envelope. private WorkerEnvelope CreateCommandEnvelope( string correlationId, WorkerCommand command) @@ -723,6 +800,10 @@ public sealed class WorkerClient : IWorkerClient envelope => envelope.WorkerCommand = command.Clone()); } + /// Creates shutdown envelope. + /// Shutdown timeout. + /// Shutdown reason. + /// The shutdown envelope. private WorkerEnvelope CreateShutdownEnvelope( TimeSpan timeout, string reason) @@ -736,6 +817,10 @@ public sealed class WorkerClient : IWorkerClient }); } + /// Creates a new worker envelope with common fields. + /// Correlation ID for the envelope. + /// Action to set envelope body. + /// The created envelope. private WorkerEnvelope CreateEnvelope( string correlationId, Action setBody) @@ -752,11 +837,17 @@ public sealed class WorkerClient : IWorkerClient return envelope; } + /// Gets the human-readable command method name. + /// The command to get method name from. + /// Command method name. private static string GetCommandMethod(WorkerCommand command) { return command.Command?.Kind.ToString() ?? MxCommandKind.Unspecified.ToString(); } + /// Creates a fault message from worker fault data. + /// The worker fault. + /// Formatted fault message. private static string CreateWorkerFaultMessage(WorkerFault fault) { return string.IsNullOrWhiteSpace(fault.DiagnosticMessage) @@ -764,6 +855,8 @@ public sealed class WorkerClient : IWorkerClient : $"Worker faulted with category {fault.Category}: {fault.DiagnosticMessage}"; } + /// Waits for all background tasks to complete. + /// Cancellation token. private async Task WaitForBackgroundTasksAsync(CancellationToken cancellationToken) { Task[] tasks = new[] { _readLoopTask, _writeLoopTask, _heartbeatLoopTask } @@ -779,6 +872,8 @@ public sealed class WorkerClient : IWorkerClient await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false); } + /// Waits for the worker process to exit. + /// Cancellation token. private async Task WaitForProcessExitAsync(CancellationToken cancellationToken) { WorkerProcessHandle? processHandle = _connection.ProcessHandle; @@ -790,6 +885,7 @@ public sealed class WorkerClient : IWorkerClient await processHandle.Process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); } + /// Throws if the client has been disposed. private void ThrowIfDisposed() { ObjectDisposedException.ThrowIf(_disposed, this); @@ -799,6 +895,10 @@ public sealed class WorkerClient : IWorkerClient { private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); + /// Initializes a pending command awaiting a worker reply. + /// Command correlation ID for reply matching. + /// Command method name. + /// Start time in milliseconds for duration tracking. public PendingCommand( string correlationId, string method, @@ -809,19 +909,27 @@ public sealed class WorkerClient : IWorkerClient StartTimestamp = startTimestamp; } + /// Gets the command correlation ID. public string CorrelationId { get; } + /// Gets the command method name. public string Method { get; } + /// Gets the command start timestamp. public long StartTimestamp { get; } + /// Gets the task that completes when reply arrives. public Task Task => _completion.Task; + /// Completes the command with a reply. + /// The command reply. public void SetResult(WorkerCommandReply reply) { _completion.TrySetResult(reply); } + /// Completes the command with an exception. + /// The exception. public void SetException(Exception exception) { _completion.TrySetException(exception); diff --git a/src/MxGateway.Server/Workers/WorkerClientConnection.cs b/src/MxGateway.Server/Workers/WorkerClientConnection.cs index b4256d0..7e3eb36 100644 --- a/src/MxGateway.Server/Workers/WorkerClientConnection.cs +++ b/src/MxGateway.Server/Workers/WorkerClientConnection.cs @@ -2,6 +2,12 @@ namespace MxGateway.Server.Workers; public sealed class WorkerClientConnection { + /// Initializes a new worker client connection. + /// Identifier of the session. + /// Worker handshake nonce. + /// Named pipe stream for IPC communication. + /// Frame protocol serialization options. + /// Worker process handle, if available. public WorkerClientConnection( string sessionId, string nonce, @@ -26,13 +32,18 @@ public sealed class WorkerClientConnection ProcessHandle = processHandle; } + /// The session ID associated with this connection. public string SessionId { get; } + /// The nonce used for handshaking with the worker. public string Nonce { get; } + /// The named pipe stream for IPC communication. public Stream Stream { get; } + /// The frame protocol options for serialization. public WorkerFrameProtocolOptions FrameOptions { get; } + /// The worker process handle, if available. public WorkerProcessHandle? ProcessHandle { get; } } diff --git a/src/MxGateway.Server/Workers/WorkerClientException.cs b/src/MxGateway.Server/Workers/WorkerClientException.cs index 8c98a6b..8b644ff 100644 --- a/src/MxGateway.Server/Workers/WorkerClientException.cs +++ b/src/MxGateway.Server/Workers/WorkerClientException.cs @@ -1,7 +1,15 @@ namespace MxGateway.Server.Workers; +/// +/// Exception raised when communication with a worker process fails. +/// public sealed class WorkerClientException : Exception { + /// + /// Initializes with an error code and message. + /// + /// Worker client error code classifying the failure. + /// Diagnostic message. public WorkerClientException( WorkerClientErrorCode errorCode, string message) @@ -10,6 +18,12 @@ public sealed class WorkerClientException : Exception ErrorCode = errorCode; } + /// + /// Initializes with an error code, message, and inner exception. + /// + /// Worker client error code classifying the failure. + /// Diagnostic message. + /// Underlying exception. public WorkerClientException( WorkerClientErrorCode errorCode, string message, @@ -19,5 +33,8 @@ public sealed class WorkerClientException : Exception ErrorCode = errorCode; } + /// + /// The worker client error code classifying the failure. + /// public WorkerClientErrorCode ErrorCode { get; } } diff --git a/src/MxGateway.Server/Workers/WorkerClientOptions.cs b/src/MxGateway.Server/Workers/WorkerClientOptions.cs index 4399654..f9af162 100644 --- a/src/MxGateway.Server/Workers/WorkerClientOptions.cs +++ b/src/MxGateway.Server/Workers/WorkerClientOptions.cs @@ -1,11 +1,18 @@ namespace MxGateway.Server.Workers; +/// Configurable options for worker client behavior. public sealed class WorkerClientOptions { + /// Default maximum age of a heartbeat before the client enters faulted state. public static readonly TimeSpan DefaultHeartbeatGrace = TimeSpan.FromSeconds(15); + + /// Default interval for checking heartbeat staleness. public static readonly TimeSpan DefaultHeartbeatCheckInterval = TimeSpan.FromSeconds(1); + + /// Default timeout when the event queue is full. public static readonly TimeSpan DefaultEventChannelFullModeTimeout = TimeSpan.FromSeconds(5); + /// Initializes options with default values. public WorkerClientOptions() { HeartbeatGrace = DefaultHeartbeatGrace; @@ -15,13 +22,18 @@ public sealed class WorkerClientOptions MaxPendingCommands = 128; } + /// Maximum allowed age of the last heartbeat before faulting the client. public TimeSpan HeartbeatGrace { get; init; } + /// Interval at which to check for heartbeat expiration. public TimeSpan HeartbeatCheckInterval { get; init; } + /// Maximum number of events buffered before backpressure is applied. public int EventChannelCapacity { get; init; } + /// Time to wait for the event queue to drain before faulting. public TimeSpan EventChannelFullModeTimeout { get; init; } + /// Maximum number of concurrent pending commands. public int MaxPendingCommands { get; init; } } diff --git a/src/MxGateway.Server/Workers/WorkerEnvelopeValidator.cs b/src/MxGateway.Server/Workers/WorkerEnvelopeValidator.cs index 9b2b4e0..c075979 100644 --- a/src/MxGateway.Server/Workers/WorkerEnvelopeValidator.cs +++ b/src/MxGateway.Server/Workers/WorkerEnvelopeValidator.cs @@ -2,8 +2,16 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Server.Workers; +/// +/// Validates worker envelope messages against protocol expectations. +/// internal static class WorkerEnvelopeValidator { + /// + /// Validates a worker envelope for protocol compliance. + /// + /// The envelope to validate. + /// The frame protocol configuration. public static void Validate( WorkerEnvelope envelope, WorkerFrameProtocolOptions options) diff --git a/src/MxGateway.Server/Workers/WorkerExecutableValidator.cs b/src/MxGateway.Server/Workers/WorkerExecutableValidator.cs index db22325..45054d2 100644 --- a/src/MxGateway.Server/Workers/WorkerExecutableValidator.cs +++ b/src/MxGateway.Server/Workers/WorkerExecutableValidator.cs @@ -13,6 +13,10 @@ internal static class WorkerExecutableValidator private const int MachineOffsetFromPeHeader = PeSignatureSize; private const int MinimumHeaderSize = 0x40; + /// Validates that a worker executable file has the required architecture. + /// Full path to the worker executable file. + /// Required CPU architecture (x86 or x64). + /// Thrown if the executable architecture does not match the required architecture. public static void Validate( string executablePath, WorkerArchitecture requiredArchitecture) @@ -35,6 +39,9 @@ internal static class WorkerExecutableValidator } } + /// Reads the PE machine type from the executable header. + /// Full path to the executable file. + /// Machine type constant from PE header. private static ushort ReadMachineType(string executablePath) { byte[] header = new byte[MinimumHeaderSize]; diff --git a/src/MxGateway.Server/Workers/WorkerFrameProtocolException.cs b/src/MxGateway.Server/Workers/WorkerFrameProtocolException.cs index 0dbfece..3dcc48d 100644 --- a/src/MxGateway.Server/Workers/WorkerFrameProtocolException.cs +++ b/src/MxGateway.Server/Workers/WorkerFrameProtocolException.cs @@ -1,7 +1,15 @@ namespace MxGateway.Server.Workers; +/// +/// Exception thrown when a worker frame protocol violation occurs. +/// public sealed class WorkerFrameProtocolException : Exception { + /// + /// Initializes a frame protocol exception with an error code and message. + /// + /// Protocol error code indicating the violation type. + /// Human-readable error message. public WorkerFrameProtocolException( WorkerFrameProtocolErrorCode errorCode, string message) @@ -10,6 +18,12 @@ public sealed class WorkerFrameProtocolException : Exception ErrorCode = errorCode; } + /// + /// Initializes a frame protocol exception with an error code, message, and inner exception. + /// + /// Protocol error code indicating the violation type. + /// Human-readable error message. + /// Underlying exception that caused this protocol violation. public WorkerFrameProtocolException( WorkerFrameProtocolErrorCode errorCode, string message, @@ -19,5 +33,8 @@ public sealed class WorkerFrameProtocolException : Exception ErrorCode = errorCode; } + /// + /// Gets the worker frame protocol error code. + /// public WorkerFrameProtocolErrorCode ErrorCode { get; } } diff --git a/src/MxGateway.Server/Workers/WorkerFrameProtocolOptions.cs b/src/MxGateway.Server/Workers/WorkerFrameProtocolOptions.cs index f7034d2..4d95237 100644 --- a/src/MxGateway.Server/Workers/WorkerFrameProtocolOptions.cs +++ b/src/MxGateway.Server/Workers/WorkerFrameProtocolOptions.cs @@ -2,10 +2,18 @@ using MxGateway.Contracts; namespace MxGateway.Server.Workers; +/// +/// Configuration for the worker frame protocol connection. +/// public sealed class WorkerFrameProtocolOptions { + /// Default maximum message size in bytes (16 MB). public const int DefaultMaxMessageBytes = 16 * 1024 * 1024; + /// + /// Initializes worker frame protocol options with a session ID. + /// + /// Identifier of the session. public WorkerFrameProtocolOptions(string sessionId) : this( sessionId, @@ -14,6 +22,12 @@ public sealed class WorkerFrameProtocolOptions { } + /// + /// Initializes worker frame protocol options with all parameters. + /// + /// Identifier of the session. + /// Protocol version number. + /// Maximum message size in bytes. public WorkerFrameProtocolOptions( string sessionId, uint protocolVersion, @@ -45,9 +59,18 @@ public sealed class WorkerFrameProtocolOptions MaxMessageBytes = maxMessageBytes; } + /// + /// Gets the session identifier. + /// public string SessionId { get; } + /// + /// Gets the worker protocol version. + /// public uint ProtocolVersion { get; } + /// + /// Gets the maximum message size in bytes. + /// public int MaxMessageBytes { get; } } diff --git a/src/MxGateway.Server/Workers/WorkerFrameReader.cs b/src/MxGateway.Server/Workers/WorkerFrameReader.cs index 360d515..8ab148b 100644 --- a/src/MxGateway.Server/Workers/WorkerFrameReader.cs +++ b/src/MxGateway.Server/Workers/WorkerFrameReader.cs @@ -9,6 +9,11 @@ public sealed class WorkerFrameReader private readonly WorkerFrameProtocolOptions _options; private readonly Stream _stream; + /// + /// Initializes a new instance of . + /// + /// Stream to read frames from. + /// Frame protocol options. public WorkerFrameReader( Stream stream, WorkerFrameProtocolOptions options) @@ -17,6 +22,11 @@ public sealed class WorkerFrameReader _options = options ?? throw new ArgumentNullException(nameof(options)); } + /// + /// Reads a worker envelope frame from the stream asynchronously. + /// + /// Cancellation token. + /// Parsed worker envelope. public async ValueTask ReadAsync(CancellationToken cancellationToken = default) { byte[] lengthPrefix = new byte[sizeof(uint)]; diff --git a/src/MxGateway.Server/Workers/WorkerFrameWriter.cs b/src/MxGateway.Server/Workers/WorkerFrameWriter.cs index bf18959..eddddaa 100644 --- a/src/MxGateway.Server/Workers/WorkerFrameWriter.cs +++ b/src/MxGateway.Server/Workers/WorkerFrameWriter.cs @@ -4,11 +4,19 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Server.Workers; +/// +/// Writes length-prefixed WorkerEnvelope protobuf messages to a stream. +/// public sealed class WorkerFrameWriter { private readonly WorkerFrameProtocolOptions _options; private readonly Stream _stream; + /// + /// Initializes the writer with a stream and frame protocol options. + /// + /// Stream to write frames to. + /// Frame protocol configuration. public WorkerFrameWriter( Stream stream, WorkerFrameProtocolOptions options) @@ -17,6 +25,11 @@ public sealed class WorkerFrameWriter _options = options ?? throw new ArgumentNullException(nameof(options)); } + /// + /// Writes a WorkerEnvelope as a length-prefixed message to the stream. + /// + /// Worker envelope message to write. + /// Token to cancel the asynchronous operation. public async ValueTask WriteAsync( WorkerEnvelope envelope, CancellationToken cancellationToken = default) diff --git a/src/MxGateway.Server/Workers/WorkerProcessCommandLine.cs b/src/MxGateway.Server/Workers/WorkerProcessCommandLine.cs index b1a670c..74d74e1 100644 --- a/src/MxGateway.Server/Workers/WorkerProcessCommandLine.cs +++ b/src/MxGateway.Server/Workers/WorkerProcessCommandLine.cs @@ -1,7 +1,15 @@ namespace MxGateway.Server.Workers; +/// +/// Represents a worker process command line. +/// public sealed class WorkerProcessCommandLine { + /// + /// Initializes a command line with executable path and arguments. + /// + /// Path to the worker executable. + /// Command-line arguments. public WorkerProcessCommandLine( string executablePath, IReadOnlyList arguments) @@ -10,10 +18,17 @@ public sealed class WorkerProcessCommandLine Arguments = arguments; } + /// + /// Gets the path to the worker executable. + /// public string ExecutablePath { get; } + /// + /// Gets the command-line arguments. + /// public IReadOnlyList Arguments { get; } + /// public override string ToString() { return string.Join( diff --git a/src/MxGateway.Server/Workers/WorkerProcessHandle.cs b/src/MxGateway.Server/Workers/WorkerProcessHandle.cs index ca94b5b..35211e3 100644 --- a/src/MxGateway.Server/Workers/WorkerProcessHandle.cs +++ b/src/MxGateway.Server/Workers/WorkerProcessHandle.cs @@ -1,7 +1,12 @@ namespace MxGateway.Server.Workers; +/// Handle to a running worker process with metadata. public sealed class WorkerProcessHandle : IDisposable { + /// Initializes a new instance of the WorkerProcessHandle class. + /// The underlying worker process. + /// The command line and arguments used to launch the process. + /// The time when the process was launched. public WorkerProcessHandle( IWorkerProcess process, WorkerProcessCommandLine commandLine, @@ -13,14 +18,19 @@ public sealed class WorkerProcessHandle : IDisposable LaunchedAt = launchedAt; } + /// Gets the underlying worker process. public IWorkerProcess Process { get; } + /// Gets the process ID. public int ProcessId { get; } + /// Gets the command line and arguments used to launch the process. public WorkerProcessCommandLine CommandLine { get; } + /// Gets the time when the process was launched. public DateTimeOffset LaunchedAt { get; } + /// public void Dispose() { Process.Dispose(); diff --git a/src/MxGateway.Server/Workers/WorkerProcessLaunchException.cs b/src/MxGateway.Server/Workers/WorkerProcessLaunchException.cs index 2ae3b5b..cb4f60f 100644 --- a/src/MxGateway.Server/Workers/WorkerProcessLaunchException.cs +++ b/src/MxGateway.Server/Workers/WorkerProcessLaunchException.cs @@ -2,6 +2,9 @@ namespace MxGateway.Server.Workers; public sealed class WorkerProcessLaunchException : Exception { + /// Initializes a new instance of the class. + /// Error code for the worker process launch failure. + /// Diagnostic message. public WorkerProcessLaunchException( WorkerProcessLaunchErrorCode errorCode, string message) @@ -10,6 +13,10 @@ public sealed class WorkerProcessLaunchException : Exception ErrorCode = errorCode; } + /// Initializes a new instance of the class with an inner exception. + /// Error code for the worker process launch failure. + /// Diagnostic message. + /// Underlying exception. public WorkerProcessLaunchException( WorkerProcessLaunchErrorCode errorCode, string message, @@ -19,5 +26,6 @@ public sealed class WorkerProcessLaunchException : Exception ErrorCode = errorCode; } + /// Gets the error code for the worker process launch failure. public WorkerProcessLaunchErrorCode ErrorCode { get; } } diff --git a/src/MxGateway.Server/Workers/WorkerProcessLauncher.cs b/src/MxGateway.Server/Workers/WorkerProcessLauncher.cs index 571b189..468fb6f 100644 --- a/src/MxGateway.Server/Workers/WorkerProcessLauncher.cs +++ b/src/MxGateway.Server/Workers/WorkerProcessLauncher.cs @@ -9,9 +9,15 @@ using Polly.Retry; namespace MxGateway.Server.Workers; +/// +/// Launches worker processes with startup probing and error handling. +/// public sealed class WorkerProcessLauncher : IWorkerProcessLauncher { + /// Environment variable for worker nonce. public const string WorkerNonceEnvironmentVariableName = "MXGATEWAY_WORKER_NONCE"; + + /// Environment variable for worker pipe connect attempt timeout. public const string WorkerPipeConnectAttemptTimeoutEnvironmentVariableName = "MXGATEWAY_WORKER_PIPE_CONNECT_ATTEMPT_TIMEOUT_MS"; @@ -22,6 +28,15 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher private readonly WorkerOptions _workerOptions; private readonly ILogger _logger; + /// + /// Initializes the worker process launcher with gateway options and dependencies. + /// + /// Gateway configuration options. + /// Factory for creating worker processes. + /// Probe for checking worker startup completion. + /// Gateway metrics collector. + /// Optional logger for diagnostic output. + /// Optional time provider for timestamps. public WorkerProcessLauncher( IOptions gatewayOptions, IWorkerProcessFactory processFactory, @@ -43,6 +58,12 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher _logger = logger ?? NullLogger.Instance; } + /// + /// Launches a worker process and waits for startup. + /// + /// Request payload. + /// Token to cancel the asynchronous operation. + /// Handle to the launched worker process. public async Task LaunchAsync( WorkerProcessLaunchRequest request, CancellationToken cancellationToken = default) diff --git a/src/MxGateway.Server/Workers/WorkerProcessStartedProbe.cs b/src/MxGateway.Server/Workers/WorkerProcessStartedProbe.cs index 947306c..b76c1fb 100644 --- a/src/MxGateway.Server/Workers/WorkerProcessStartedProbe.cs +++ b/src/MxGateway.Server/Workers/WorkerProcessStartedProbe.cs @@ -2,6 +2,11 @@ namespace MxGateway.Server.Workers; public sealed class WorkerProcessStartedProbe : IWorkerStartupProbe { + /// Verifies that the worker process has started and has not exited. + /// Worker process to verify. + /// Process launch request. + /// Token to cancel the asynchronous operation. + /// Completed task if process is running. public Task WaitUntilReadyAsync( IWorkerProcess process, WorkerProcessLaunchRequest request, diff --git a/src/MxGateway.Server/Workers/WorkerServiceCollectionExtensions.cs b/src/MxGateway.Server/Workers/WorkerServiceCollectionExtensions.cs index 7a0b961..d387573 100644 --- a/src/MxGateway.Server/Workers/WorkerServiceCollectionExtensions.cs +++ b/src/MxGateway.Server/Workers/WorkerServiceCollectionExtensions.cs @@ -1,7 +1,10 @@ namespace MxGateway.Server.Workers; +/// Service collection extensions for worker process management. public static class WorkerServiceCollectionExtensions { + /// Registers worker process launcher and factory services. + /// Service collection to register services. public static IServiceCollection AddWorkerProcessLauncher(this IServiceCollection services) { services.AddSingleton(); diff --git a/src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs b/src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs index aec1e83..847935a 100644 --- a/src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs +++ b/src/MxGateway.Tests/Configuration/GatewayOptionsTests.cs @@ -7,6 +7,7 @@ namespace MxGateway.Tests.Configuration; public sealed class GatewayOptionsTests { + /// Verifies that options binding uses design defaults when no configuration is provided. [Fact] public void OptionsBinding_UsesDesignDefaults() { @@ -47,6 +48,7 @@ public sealed class GatewayOptionsTests Assert.Equal(1u, options.Protocol.WorkerProtocolVersion); } + /// Verifies that options binding applies configuration overrides. [Fact] public void OptionsBinding_AppliesConfigurationOverrides() { @@ -67,6 +69,10 @@ public sealed class GatewayOptionsTests Assert.False(options.Dashboard.Enabled); } + /// Verifies that invalid configuration values fail with expected error messages. + /// Configuration key being validated. + /// Configuration value being tested. + /// Expected validation error message. [Theory] [InlineData("MxGateway:Worker:ExecutablePath", "worker.dll", "MxGateway:Worker:ExecutablePath must point to a .exe file.")] [InlineData("MxGateway:Worker:StartupProbeRetryAttempts", "0", "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.")] @@ -82,6 +88,7 @@ public sealed class GatewayOptionsTests Assert.Contains(exception.Failures, failure => failure.Contains(expectedFailure, StringComparison.Ordinal)); } + /// Verifies that pepper secret names are redacted in the effective configuration. [Fact] public void EffectiveConfiguration_RedactsPepperSecretName() { diff --git a/src/MxGateway.Tests/Contracts/ClientBehaviorFixtureTests.cs b/src/MxGateway.Tests/Contracts/ClientBehaviorFixtureTests.cs index d48979e..b987949 100644 --- a/src/MxGateway.Tests/Contracts/ClientBehaviorFixtureTests.cs +++ b/src/MxGateway.Tests/Contracts/ClientBehaviorFixtureTests.cs @@ -5,10 +5,16 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Tests.Contracts; +/// +/// Tests for behavior fixture manifests and contract conformance. +/// public sealed class ClientBehaviorFixtureTests { private static readonly JsonParser ProtobufJsonParser = new(JsonParser.Settings.Default); + /// + /// Verifies the behavior manifest declares correct protocol versions and fixture references. + /// [Fact] public void BehaviorManifest_DeclaresCurrentProtocolVersionsAndExistingFixtures() { @@ -39,6 +45,9 @@ public sealed class ClientBehaviorFixtureTests } } + /// + /// Verifies proto inputs manifest references the expected behavior fixture root. + /// [Fact] public void ProtoInputManifest_ReferencesBehaviorFixtureRoot() { @@ -52,6 +61,9 @@ public sealed class ClientBehaviorFixtureTests Assert.True(Directory.Exists(Path.Combine(repositoryRoot.FullName, fixtureRoot))); } + /// + /// Verifies command reply fixtures parse and preserve MXAccess details. + /// [Fact] public void CommandReplyFixtures_ParseWithCurrentContractAndPreserveMxAccessDetails() { @@ -84,6 +96,9 @@ public sealed class ClientBehaviorFixtureTests Assert.All(failedWrite.Statuses, status => Assert.Equal(0, status.Success)); } + /// + /// Verifies event stream fixtures have monotonic sequences and expected event families. + /// [Fact] public void EventStreamFixtures_ParseWithMonotonicSequencesAndExpectedFamilies() { @@ -116,6 +131,9 @@ public sealed class ClientBehaviorFixtureTests } } + /// + /// Verifies value conversion fixtures parse typed values and raw fallbacks. + /// [Fact] public void ValueConversionFixtures_ParseTypedValuesAndRawFallbacks() { @@ -149,6 +167,9 @@ public sealed class ClientBehaviorFixtureTests Assert.True(sawTypedArray, "Expected at least one typed array case."); } + /// + /// Verifies status conversion fixtures parse status arrays and raw fields. + /// [Fact] public void StatusConversionFixtures_ParseStatusArraysAndRawFields() { @@ -172,6 +193,9 @@ public sealed class ClientBehaviorFixtureTests Assert.True(sawRawUnknown, "Expected a status case with unknown raw native fields."); } + /// + /// Verifies auth error fixtures map authentication/authorization and redact credentials. + /// [Fact] public void AuthErrorFixtures_MapAuthenticationAuthorizationAndRedactCredentials() { @@ -203,6 +227,7 @@ public sealed class ClientBehaviorFixtureTests Assert.Contains("PERMISSION_DENIED", statusCodes); } + /// Verifies timeout and cancellation test fixtures document client and worker behavior. [Fact] public void TimeoutCancelFixtures_DocumentClientWaitAndWorkerCommandBehavior() { diff --git a/src/MxGateway.Tests/Contracts/ClientProtoInputTests.cs b/src/MxGateway.Tests/Contracts/ClientProtoInputTests.cs index 684abe5..5db3949 100644 --- a/src/MxGateway.Tests/Contracts/ClientProtoInputTests.cs +++ b/src/MxGateway.Tests/Contracts/ClientProtoInputTests.cs @@ -7,6 +7,7 @@ namespace MxGateway.Tests.Contracts; public sealed class ClientProtoInputTests { + /// Verifies that the proto inputs manifest declares current protocol versions and existing source files. [Fact] public void Manifest_DeclaresCurrentProtocolVersionsAndExistingInputs() { @@ -34,6 +35,7 @@ public sealed class ClientProtoInputTests } } + /// Verifies that the OpenSessionReply fixture parses with the current contract version. [Fact] public void OpenSessionReplyFixture_ParsesWithCurrentContract() { @@ -46,6 +48,7 @@ public sealed class ClientProtoInputTests Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); } + /// Verifies that the RegisterCommand fixture parses with the current contract version. [Fact] public void RegisterCommandRequestFixture_ParsesWithCurrentContract() { @@ -57,6 +60,7 @@ public sealed class ClientProtoInputTests Assert.Equal("fixture-client", request.Command.Register.ClientName); } + /// Verifies that the OnDataChange event fixture parses with the current contract version. [Fact] public void OnDataChangeEventFixture_ParsesWithCurrentContract() { diff --git a/src/MxGateway.Tests/Contracts/CrossLanguageSmokeMatrixTests.cs b/src/MxGateway.Tests/Contracts/CrossLanguageSmokeMatrixTests.cs index 6d042f8..9f73236 100644 --- a/src/MxGateway.Tests/Contracts/CrossLanguageSmokeMatrixTests.cs +++ b/src/MxGateway.Tests/Contracts/CrossLanguageSmokeMatrixTests.cs @@ -4,6 +4,7 @@ namespace MxGateway.Tests.Contracts; public sealed class CrossLanguageSmokeMatrixTests { + /// Verifies that the smoke matrix declares the integration gate and JSON comparison shape. [Fact] public void Matrix_DeclaresIntegrationGateAndComparisonShape() { @@ -44,6 +45,7 @@ public sealed class CrossLanguageSmokeMatrixTests AssertForbiddenLiterals(authContext.GetProperty("forbiddenLiterals")); } + /// Verifies that the smoke matrix covers every supported client with equivalent smoke steps. [Fact] public void Matrix_CoversEverySupportedClientWithEquivalentSmokeSteps() { @@ -70,6 +72,7 @@ public sealed class CrossLanguageSmokeMatrixTests Assert.Equal(ExpectedLanguages.OrderBy(language => language, StringComparer.Ordinal), clientsByLanguage.Keys.OrderBy(language => language, StringComparer.Ordinal)); } + /// Verifies that the smoke matrix keeps live smoke opt-in and secrets out of commands. [Fact] public void Matrix_KeepsLiveSmokeOptInAndSecretsOutOfCommands() { diff --git a/src/MxGateway.Tests/Contracts/GatewayContractInfoTests.cs b/src/MxGateway.Tests/Contracts/GatewayContractInfoTests.cs index 65456fb..16e0c42 100644 --- a/src/MxGateway.Tests/Contracts/GatewayContractInfoTests.cs +++ b/src/MxGateway.Tests/Contracts/GatewayContractInfoTests.cs @@ -4,18 +4,21 @@ namespace MxGateway.Tests.Contracts; public sealed class GatewayContractInfoTests { + /// Verifies that the default backend name is "mxaccess-worker". [Fact] public void DefaultBackendName_IsMxAccessWorker() { Assert.Equal("mxaccess-worker", GatewayContractInfo.DefaultBackendName); } + /// Verifies that the gateway protocol version starts at version one. [Fact] public void GatewayProtocolVersion_StartsAtVersionOne() { Assert.Equal(1u, GatewayContractInfo.GatewayProtocolVersion); } + /// Verifies that the worker protocol version starts at version one. [Fact] public void WorkerProtocolVersion_StartsAtVersionOne() { diff --git a/src/MxGateway.Tests/Contracts/ParityFixtureMatrixTests.cs b/src/MxGateway.Tests/Contracts/ParityFixtureMatrixTests.cs index 0a2f546..d86f1f8 100644 --- a/src/MxGateway.Tests/Contracts/ParityFixtureMatrixTests.cs +++ b/src/MxGateway.Tests/Contracts/ParityFixtureMatrixTests.cs @@ -5,6 +5,7 @@ namespace MxGateway.Tests.Contracts; public sealed class ParityFixtureMatrixTests { + /// Verifies that the parity matrix declares current protocol versions and comparison fields. [Fact] public void Matrix_DeclaresCurrentProtocolVersionsAndComparisonFields() { @@ -52,6 +53,7 @@ public sealed class ParityFixtureMatrixTests "rawFallbackMetadata"); } + /// Verifies that the parity matrix covers every public MXAccess method. [Fact] public void Matrix_CoversEveryPublicMxAccessMethod() { @@ -89,6 +91,7 @@ public sealed class ParityFixtureMatrixTests } } + /// Verifies that the parity matrix covers required scenario groups. [Fact] public void Matrix_CoversRequiredParityScenarioGroups() { @@ -124,6 +127,7 @@ public sealed class ParityFixtureMatrixTests AssertScenarioCovers(groupsById["buffered_registration"], "method.add-buffered-item.context", "event.on-buffered-data-change.batch-gap"); } + /// Verifies that the parity matrix covers every public MXAccess event family. [Fact] public void Matrix_CoversEveryPublicMxAccessEventFamily() { diff --git a/src/MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs b/src/MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs index 411b481..d077935 100644 --- a/src/MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs +++ b/src/MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs @@ -7,6 +7,7 @@ namespace MxGateway.Tests.Contracts; public sealed class ProtobufContractRoundTripTests { + /// Verifies that gateway descriptor contains expected public service methods. [Fact] public void GatewayDescriptor_ContainsInitialPublicServiceMethods() { @@ -20,6 +21,7 @@ public sealed class ProtobufContractRoundTripTests Assert.Contains(service.Methods, method => method.Name == "StreamEvents"); } + /// Verifies that worker envelope descriptor contains required correlation fields. [Fact] public void WorkerEnvelopeDescriptor_ContainsRequiredCorrelationFields() { @@ -31,6 +33,7 @@ public sealed class ProtobufContractRoundTripTests Assert.Contains(fields, field => field.Name == "correlation_id"); } + /// Verifies that command request round-trips through serialization. [Fact] public void CommandRequest_RoundTripsMethodSpecificPayload() { @@ -54,6 +57,7 @@ public sealed class ProtobufContractRoundTripTests Assert.Equal(MxCommand.PayloadOneofCase.Register, parsed.Command.PayloadCase); } + /// Verifies that command reply round-trips with return values and statuses. [Fact] public void CommandReply_RoundTripsHResultReturnValueOutParamsAndStatuses() { @@ -94,6 +98,7 @@ public sealed class ProtobufContractRoundTripTests Assert.Single(parsed.Statuses); } + /// Verifies that event round-trips with value, status, and sequence. [Fact] public void Event_RoundTripsValueStatusSequenceAndBufferedBody() { @@ -161,6 +166,7 @@ public sealed class ProtobufContractRoundTripTests Assert.Single(parsed.Statuses); } + /// Verifies that worker envelope round-trips through serialization preserving protocol and command fields. [Fact] public void WorkerEnvelope_RoundTripsProtocolFieldsAndCommandBody() { diff --git a/src/MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs b/src/MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs index 0034713..145c296 100644 --- a/src/MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs +++ b/src/MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs @@ -4,6 +4,7 @@ namespace MxGateway.Tests.Diagnostics; public sealed class GatewayLogRedactorTests { + /// Verifies that RedactApiKey preserves the key ID and removes the secret. [Fact] public void RedactApiKey_PreservesKeyIdAndRemovesSecret() { @@ -13,6 +14,7 @@ public sealed class GatewayLogRedactorTests Assert.DoesNotContain("super-secret", redacted); } + /// Verifies that RedactApiKey removes secrets containing underscores. [Fact] public void RedactApiKey_RemovesSecretContainingUnderscores() { @@ -22,6 +24,8 @@ public sealed class GatewayLogRedactorTests Assert.DoesNotContain("super_secret_value", redacted); } + /// Verifies that IsCredentialBearingCommand identifies credential-bearing MXAccess commands. + /// Name of the MXAccess command method. [Theory] [InlineData("AuthenticateUser")] [InlineData("WriteSecured")] @@ -31,6 +35,7 @@ public sealed class GatewayLogRedactorTests Assert.True(GatewayLogRedactor.IsCredentialBearingCommand(commandMethod)); } + /// Verifies that RedactCommandValue does not log raw values by default. [Fact] public void RedactCommandValue_DoesNotLogRawValuesByDefault() { @@ -39,6 +44,7 @@ public sealed class GatewayLogRedactorTests Assert.Equal("[redacted]", redacted); } + /// Verifies that RedactCommandValue redacts secured writes even when value logging is enabled. [Fact] public void RedactCommandValue_RedactsSecuredWriteEvenWhenValueLoggingIsEnabled() { @@ -50,6 +56,7 @@ public sealed class GatewayLogRedactorTests Assert.Equal("[redacted]", redacted); } + /// Verifies that RedactCommandValue allows non-sensitive values only when value logging is enabled. [Fact] public void RedactCommandValue_AllowsNonSensitiveValueOnlyWhenValueLoggingIsEnabled() { @@ -61,6 +68,7 @@ public sealed class GatewayLogRedactorTests Assert.Equal("diagnostic-value", redacted); } + /// Verifies that LogScope redacts client identity before scope state is created. [Fact] public void LogScope_RedactsClientIdentityBeforeScopeStateIsCreated() { diff --git a/src/MxGateway.Tests/Galaxy/GalaxyDeployNotifierTests.cs b/src/MxGateway.Tests/Galaxy/GalaxyDeployNotifierTests.cs index ca34766..9af1d79 100644 --- a/src/MxGateway.Tests/Galaxy/GalaxyDeployNotifierTests.cs +++ b/src/MxGateway.Tests/Galaxy/GalaxyDeployNotifierTests.cs @@ -4,6 +4,7 @@ namespace MxGateway.Tests.Galaxy; public sealed class GalaxyDeployNotifierTests { + /// Verifies that a subscriber blocks until a deploy event is published. [Fact] public async Task SubscribeAsync_NoLatestEvent_BlocksUntilPublish() { @@ -32,6 +33,7 @@ public sealed class GalaxyDeployNotifierTests await enumerator.DisposeAsync(); } + /// Verifies that a subscriber immediately receives a cached latest deploy event. [Fact] public async Task SubscribeAsync_WithLatestEvent_BootstrapsImmediately() { @@ -50,6 +52,7 @@ public sealed class GalaxyDeployNotifierTests await cts.CancelAsync(); } + /// Verifies that published events fan out to all active subscribers. [Fact] public async Task Publish_FansOutToAllSubscribers() { @@ -74,6 +77,7 @@ public sealed class GalaxyDeployNotifierTests await cts.CancelAsync(); } + /// Verifies that the Latest property tracks the most recently published event. [Fact] public void Latest_TracksMostRecentPublish() { diff --git a/src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs b/src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs index 5e3558d..0eba2de 100644 --- a/src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs +++ b/src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs @@ -4,6 +4,9 @@ namespace MxGateway.Tests.Galaxy; public sealed class GalaxyHierarchyCacheTests { + /// + /// Verifies cache returns empty entry before any refresh occurs. + /// [Fact] public void Current_BeforeAnyRefresh_ReturnsEmpty() { @@ -18,6 +21,9 @@ public sealed class GalaxyHierarchyCacheTests Assert.Null(entry.Reply); } + /// + /// Verifies cache marks unavailable and does not publish when SQL is unreachable. + /// [Fact] public async Task RefreshAsync_WhenSqlIsUnreachable_MarksUnavailableAndDoesNotPublish() { @@ -33,6 +39,9 @@ public sealed class GalaxyHierarchyCacheTests Assert.True(cache.WaitForFirstLoadAsync(CancellationToken.None).IsCompletedSuccessfully); } + /// + /// Verifies HasData returns true for healthy cache entries. + /// [Fact] public void HasData_OnHealthyEntry_IsTrue() { @@ -46,6 +55,9 @@ public sealed class GalaxyHierarchyCacheTests Assert.True(entry.HasData); } + /// + /// Verifies HasData returns false for unknown cache entries. + /// [Fact] public void HasData_OnUnknownEntry_IsFalse() { @@ -67,8 +79,13 @@ public sealed class GalaxyHierarchyCacheTests { private DateTimeOffset _now = start == default ? DateTimeOffset.UtcNow : start; + /// public override DateTimeOffset GetUtcNow() => _now; + /// + /// Advances the current time by the specified duration. + /// + /// Time duration to advance. public void Advance(TimeSpan duration) => _now += duration; } } diff --git a/src/MxGateway.Tests/Galaxy/GalaxyProtoMapperTests.cs b/src/MxGateway.Tests/Galaxy/GalaxyProtoMapperTests.cs index 2a6ecc6..d1de74a 100644 --- a/src/MxGateway.Tests/Galaxy/GalaxyProtoMapperTests.cs +++ b/src/MxGateway.Tests/Galaxy/GalaxyProtoMapperTests.cs @@ -6,6 +6,7 @@ namespace MxGateway.Tests.Galaxy; public sealed class GalaxyProtoMapperTests { + /// Verifies that mapping a galaxy attribute row preserves all scalar fields. [Fact] public void MapAttribute_PreservesAllScalarFields() { @@ -40,6 +41,7 @@ public sealed class GalaxyProtoMapperTests Assert.False(proto.IsAlarm); } + /// Verifies that the array dimension present flag distinguishes null from zero. [Fact] public void MapAttribute_ArrayDimensionPresentFlag_DistinguishesNullFromZero() { @@ -53,6 +55,7 @@ public sealed class GalaxyProtoMapperTests Assert.Equal(0, GalaxyProtoMapper.MapAttribute(withoutDim).ArrayDimension); } + /// Verifies that null data type name becomes an empty string. [Fact] public void MapAttribute_NullDataTypeName_BecomesEmptyString() { @@ -63,6 +66,7 @@ public sealed class GalaxyProtoMapperTests Assert.Equal(string.Empty, proto.DataTypeName); } + /// Verifies that MapHierarchy groups attributes by GobjectId. [Fact] public void MapHierarchy_GroupsAttributesByGobjectId() { @@ -88,6 +92,7 @@ public sealed class GalaxyProtoMapperTests Assert.Empty(result[2].Attributes); } + /// Verifies that MapObject copies the template chain. [Fact] public void MapObject_CopiesTemplateChain() { diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs index aa7dfa5..d4a0a24 100644 --- a/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs @@ -7,8 +7,14 @@ using MxGateway.Server.Security.Authorization; namespace MxGateway.Tests.Gateway.Dashboard; +/// +/// Tests for dashboard authentication using API keys. +/// public sealed class DashboardAuthenticatorTests { + /// + /// Verifies an admin-scoped key produces a valid cookie principal. + /// [Fact] public async Task AuthenticateAsync_AdminKey_ReturnsCookiePrincipal() { @@ -29,6 +35,9 @@ public sealed class DashboardAuthenticatorTests Assert.Equal("Bearer mxgw_operator01_super-secret", verifier.LastAuthorizationHeader); } + /// + /// Verifies a non-admin key fails authentication without exposing the API key. + /// [Fact] public async Task AuthenticateAsync_NonAdminKey_ReturnsFailureWithoutRawApiKey() { @@ -44,6 +53,9 @@ public sealed class DashboardAuthenticatorTests Assert.DoesNotContain("super-secret", result.FailureMessage, StringComparison.Ordinal); } + /// + /// Verifies that when admin scope is not required, any authenticated key is accepted. + /// [Fact] public async Task AuthenticateAsync_RequireAdminScopeFalse_AllowsAuthenticatedKey() { @@ -59,6 +71,9 @@ public sealed class DashboardAuthenticatorTests Assert.NotNull(result.Principal); } + /// + /// Verifies an invalid key returns a generic failure message. + /// [Fact] public async Task AuthenticateAsync_InvalidKey_ReturnsGenericFailure() { @@ -97,10 +112,17 @@ public sealed class DashboardAuthenticatorTests Scopes: new HashSet(scopes, StringComparer.Ordinal))); } + /// + /// Test implementation that records the authorization header for verification. + /// private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier { + /// + /// The authorization header that was last verified. + /// public string? LastAuthorizationHeader { get; private set; } + /// public Task VerifyAsync( string? authorizationHeader, CancellationToken cancellationToken) diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs index 7683b4f..cde3b0b 100644 --- a/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardAuthorizationHandlerTests.cs @@ -11,6 +11,7 @@ namespace MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardAuthorizationHandlerTests { + /// Verifies that unauthenticated remote requests fail authorization. [Fact] public async Task HandleAsync_UnauthenticatedRemoteRequest_DoesNotSucceed() { @@ -22,6 +23,7 @@ public sealed class DashboardAuthorizationHandlerTests Assert.False(context.HasSucceeded); } + /// Verifies that anonymous localhost access succeeds when allowed. [Fact] public async Task HandleAsync_AnonymousLocalhostAllowed_Succeeds() { @@ -33,6 +35,7 @@ public sealed class DashboardAuthorizationHandlerTests Assert.True(context.HasSucceeded); } + /// Verifies that authenticated users without admin scope fail authorization. [Fact] public async Task HandleAsync_AuthenticatedWithoutAdminScope_DoesNotSucceed() { @@ -44,6 +47,7 @@ public sealed class DashboardAuthorizationHandlerTests Assert.False(context.HasSucceeded); } + /// Verifies that authenticated users with admin scope succeed. [Fact] public async Task HandleAsync_AuthenticatedWithAdminScope_Succeeds() { diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs index f643986..df78898 100644 --- a/src/MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardCookieOptionsTests.cs @@ -10,6 +10,7 @@ namespace MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardCookieOptionsTests { + /// Verifies that the application configures secure dashboard authentication cookies. [Fact] public void Build_ConfiguresSecureDashboardCookie() { diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs index 3117109..60b06c6 100644 --- a/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs @@ -11,6 +11,9 @@ namespace MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardSnapshotServiceTests { + /// + /// Verifies snapshot returns empty collections and healthy status when registry is empty. + /// [Fact] public void GetSnapshot_WhenRegistryEmpty_ReturnsEmptyOperationalState() { @@ -27,6 +30,9 @@ public sealed class DashboardSnapshotServiceTests Assert.NotNull(snapshot.Configuration); } + /// + /// Verifies snapshot projects active, faulted, and closed session states with worker and metrics data. + /// [Fact] public void GetSnapshot_ProjectsActiveAndFaultedSessionsWorkersMetricsAndFaults() { @@ -84,6 +90,9 @@ public sealed class DashboardSnapshotServiceTests Assert.Equal("worker pipe disconnected", fault.Message); } + /// + /// Verifies snapshot redacts sensitive values from client identity, session name, and fault messages. + /// [Fact] public void GetSnapshot_RedactsSecretsFromSessionAndFaultFields() { @@ -110,6 +119,9 @@ public sealed class DashboardSnapshotServiceTests Assert.Equal("[redacted]", snapshot.Configuration.Authentication.PepperSecretName); } + /// + /// Verifies snapshot generation does not mutate session or worker client state. + /// [Fact] public void GetSnapshot_DoesNotMutateSessionOrWorkerState() { @@ -136,6 +148,9 @@ public sealed class DashboardSnapshotServiceTests Assert.Equal(0, workerClient.KillCount); } + /// + /// Verifies snapshot respects configured limits for recent sessions and faults. + /// [Fact] public void GetSnapshot_AppliesRecentSessionAndFaultLimits() { @@ -172,6 +187,9 @@ public sealed class DashboardSnapshotServiceTests Assert.Equal("session-newer", Assert.Single(snapshot.Faults).SessionId); } + /// + /// Verifies snapshot projects Galaxy hierarchy cache data including templates and categories. + /// [Fact] public void GetSnapshot_ProjectsGalaxySummaryFromHierarchyCache() { @@ -217,6 +235,9 @@ public sealed class DashboardSnapshotServiceTests Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1); } + /// + /// Verifies snapshot watcher cancels cleanly when subscriber cancels. + /// [Fact] public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly() { @@ -268,10 +289,23 @@ public sealed class DashboardSnapshotServiceTests private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache { + /// + /// Gets the current Galaxy hierarchy cache entry. + /// public GalaxyHierarchyCacheEntry Current { get; } = current; + /// + /// Refreshes the cache asynchronously. + /// + /// Cancellation token. + /// Completed task. public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; + /// + /// Waits for the first cache load asynchronously. + /// + /// Cancellation token. + /// Completed task. public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; } @@ -301,26 +335,59 @@ public sealed class DashboardSnapshotServiceTests int? processId, WorkerClientState state) : IWorkerClient { + /// + /// Gets the session identifier. + /// public string SessionId { get; } = sessionId; + /// + /// Gets the process identifier. + /// public int? ProcessId { get; } = processId; + /// + /// Gets the current worker client state. + /// public WorkerClientState State { get; private set; } = state; + /// + /// Gets the timestamp of the last heartbeat. + /// public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z"); + /// + /// Gets the count of start invocations. + /// public int StartCount { get; private set; } + /// + /// Gets the count of shutdown invocations. + /// public int ShutdownCount { get; private set; } + /// + /// Gets the count of kill invocations. + /// public int KillCount { get; private set; } + /// + /// Starts the worker client asynchronously. + /// + /// Cancellation token. + /// Completed task. public Task StartAsync(CancellationToken cancellationToken) { StartCount++; return Task.CompletedTask; } + /// + /// Invokes a worker command asynchronously. + /// + /// The command to invoke. + /// Command timeout. + /// Cancellation token. + /// Command reply. public Task InvokeAsync( WorkerCommand command, TimeSpan timeout, @@ -329,6 +396,11 @@ public sealed class DashboardSnapshotServiceTests return Task.FromResult(new WorkerCommandReply()); } + /// + /// Reads events from the worker asynchronously. + /// + /// Cancellation token. + /// Async enumerable of worker events. public async IAsyncEnumerable ReadEventsAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { @@ -336,6 +408,12 @@ public sealed class DashboardSnapshotServiceTests yield break; } + /// + /// Shuts down the worker client asynchronously. + /// + /// Shutdown timeout. + /// Cancellation token. + /// Completed task. public Task ShutdownAsync( TimeSpan timeout, CancellationToken cancellationToken) @@ -345,12 +423,20 @@ public sealed class DashboardSnapshotServiceTests return Task.CompletedTask; } + /// + /// Terminates the worker client. + /// + /// Reason for termination. public void Kill(string reason) { KillCount++; State = WorkerClientState.Faulted; } + /// + /// Releases resources used by this worker client. + /// + /// Completed value task. public ValueTask DisposeAsync() { return ValueTask.CompletedTask; diff --git a/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs b/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs index a04a6b1..bf80c9e 100644 --- a/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs +++ b/src/MxGateway.Tests/Gateway/GatewayApplicationTests.cs @@ -9,6 +9,7 @@ namespace MxGateway.Tests.Gateway; public sealed class GatewayApplicationTests { + /// Verifies that Build maps the live health check endpoint. [Fact] public void Build_MapsLiveHealthEndpoint() { @@ -23,6 +24,7 @@ public sealed class GatewayApplicationTests Assert.Equal("LiveHealth", endpoint.Metadata.GetMetadata()?.EndpointName); } + /// Verifies that Build registers the gateway metrics service. [Fact] public void Build_RegistersGatewayMetrics() { @@ -33,6 +35,7 @@ public sealed class GatewayApplicationTests Assert.NotNull(metrics); } + /// Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled. [Fact] public void Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints() { @@ -50,6 +53,7 @@ public sealed class GatewayApplicationTests endpoint.Metadata.GetMetadata()?.EndpointName == "DashboardLogout"); } + /// Verifies that Build does not map dashboard routes when the dashboard is disabled. [Fact] public void Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes() { @@ -64,6 +68,10 @@ public sealed class GatewayApplicationTests StringComparison.Ordinal) == true); } + /// Verifies that StartAsync fails when gateway configuration is invalid. + /// Configuration key to override. + /// Invalid configuration value. + /// Expected validation error message. [Theory] [InlineData( "MxGateway:Worker:ExecutablePath", diff --git a/src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs b/src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs index 274ac67..5f3a985 100644 --- a/src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs +++ b/src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs @@ -21,6 +21,9 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests private const int ServerHandle = 1001; private const int ItemHandle = 2002; + /// + /// Verifies gateway session lifecycle with a scripted fake worker: open, command, event, close. + /// [Fact] public async Task GatewayService_WithFakeWorker_CompletesSessionCommandEventAndClosePath() { @@ -146,6 +149,10 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests private readonly GatewayMetrics _metrics = new(); private readonly SessionRegistry _registry = new(); + /// + /// Initializes a new instance of . + /// + /// Worker process launcher for the fixture. public GatewayServiceFixture(IWorkerProcessLauncher launcher) { IOptions options = Options.Create(CreateOptions()); @@ -178,8 +185,14 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests NullLogger.Instance); } + /// + /// Gets the configured gateway service instance. + /// public MxAccessGatewayService Service { get; } + /// + /// Disposes all active sessions and metrics. + /// public async ValueTask DisposeAsync() { foreach (GatewaySession session in _registry.Snapshot()) @@ -220,12 +233,27 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests public const int ProcessId = 4680; private readonly ConcurrentQueue _commandKinds = new(); + /// + /// Gets the fake worker process instance. + /// public FakeWorkerProcess Process { get; } = new(ProcessId); + /// + /// Gets the collection of command kinds processed by the worker. + /// public IReadOnlyCollection CommandKinds => _commandKinds.ToArray(); + /// + /// Gets the worker's asynchronous task. + /// public Task WorkerTask { get; private set; } = Task.CompletedTask; + /// + /// Launches a new worker process and returns a handle to manage it. + /// + /// Worker process launch request parameters. + /// Cancellation token. + /// Worker process handle. public Task LaunchAsync( WorkerProcessLaunchRequest request, CancellationToken cancellationToken = default) @@ -321,12 +349,26 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests private sealed class FakeWorkerProcess(int processId) : IWorkerProcess { + /// + /// Gets the process identifier. + /// public int Id { get; } = processId; + /// + /// Gets a value indicating whether the process has exited. + /// public bool HasExited { get; private set; } + /// + /// Gets the exit code of the process. + /// public int? ExitCode { get; private set; } + /// + /// Waits for the process to exit asynchronously. + /// + /// Cancellation token. + /// Completed task. public ValueTask WaitForExitAsync(CancellationToken cancellationToken) { HasExited = true; @@ -334,15 +376,26 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests return ValueTask.CompletedTask; } + /// + /// Terminates the process. + /// + /// Whether to kill the entire process tree. public void Kill(bool entireProcessTree) { MarkExited(-1); } + /// + /// Releases resources used by this process. + /// public void Dispose() { } + /// + /// Marks the process as exited with the specified exit code. + /// + /// The process exit code. public void MarkExited(int exitCode) { HasExited = true; @@ -356,6 +409,9 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests private readonly TaskCompletionSource _firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly List _messages = []; + /// + /// Gets the recorded messages written to this stream. + /// public IReadOnlyList Messages { get @@ -367,8 +423,16 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests } } + /// + /// Gets or sets options for writing messages to the stream. + /// public WriteOptions? WriteOptions { get; set; } + /// + /// Writes a message to the stream asynchronously. + /// + /// The message to write. + /// Completed task. public Task WriteAsync(T message) { lock (_syncRoot) @@ -380,6 +444,11 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests return Task.CompletedTask; } + /// + /// Waits for the first message to be written within the specified timeout. + /// + /// Maximum time to wait for the first message. + /// The first message written to this stream. public async Task WaitForFirstMessageAsync(TimeSpan timeout) { return await _firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false); @@ -394,43 +463,66 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests 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; + /// + /// Writes response headers asynchronously. + /// + /// Headers to write. + /// Completed task. + /// protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) { return Task.CompletedTask; } + /// + /// Creates a context propagation token with the specified options. + /// + /// Propagation options. + /// Propagation token. + /// protected override ContextPropagationToken CreatePropagationTokenCore( ContextPropagationOptions? options) { diff --git a/src/MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs b/src/MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs index 428b672..3d2264b 100644 --- a/src/MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs +++ b/src/MxGateway.Tests/Gateway/Grpc/EventStreamServiceTests.cs @@ -16,6 +16,7 @@ public sealed class EventStreamServiceTests { private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + /// Verifies that events from the worker stream maintain their original sequence order. [Fact] public async Task StreamEventsAsync_YieldsEventsInWorkerOrder() { @@ -36,6 +37,7 @@ public sealed class EventStreamServiceTests Assert.Equal(1, metrics.GetSnapshot().StreamDisconnects); } + /// Verifies that a second event subscriber is rejected when one is already active. [Fact] public async Task StreamEventsAsync_WhenSecondSubscriberStarts_RejectsClearly() { @@ -64,6 +66,7 @@ public sealed class EventStreamServiceTests await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 0); } + /// Verifies that canceling an event stream detaches the subscriber cleanly. [Fact] public async Task StreamEventsAsync_WhenCanceled_DetachesSubscriber() { @@ -85,6 +88,7 @@ public sealed class EventStreamServiceTests await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 0); } + /// Verifies that disposing an event stream with buffered events resets the queue depth metric. [Fact] public async Task StreamEventsAsync_WhenDisposedWithBufferedEvents_ResetsStreamQueueDepth() { @@ -111,6 +115,7 @@ public sealed class EventStreamServiceTests await WaitUntilAsync(() => metrics.GetSnapshot().GrpcEventStreamQueueDepth == 0); } + /// Verifies that queue depth metrics correctly track concurrent event streams across multiple sessions. [Fact] public async Task StreamEventsAsync_WithConcurrentStreams_TracksAggregateQueueDepth() { @@ -151,6 +156,7 @@ public sealed class EventStreamServiceTests await WaitUntilAsync(() => metrics.GetSnapshot().GrpcEventStreamQueueDepth == 0); } + /// Verifies that event queue overflow faults the session and reports the overflow metric. [Fact] public async Task StreamEventsAsync_WhenStreamQueueOverflows_FaultsSessionAndReportsOverflow() { @@ -180,6 +186,7 @@ public sealed class EventStreamServiceTests Assert.Equal(1, metrics.GetSnapshot().Faults); } + /// Verifies that the disconnect backpressure policy disconnects the subscriber without faulting the session. [Fact] public async Task StreamEventsAsync_WhenStreamQueueOverflowsWithDisconnectPolicy_LeavesSessionReady() { @@ -211,6 +218,7 @@ public sealed class EventStreamServiceTests Assert.Equal(1, snapshot.StreamDisconnects); } + /// Verifies that the event stream does not synthesize OperationComplete events from write completions. [Fact] public async Task StreamEventsAsync_DoesNotSynthesizeOperationComplete() { @@ -227,6 +235,7 @@ public sealed class EventStreamServiceTests Assert.DoesNotContain(events, candidate => candidate.Family == MxEventFamily.OperationComplete); } + /// Verifies that a terminal fault from the worker event stream propagates and faults the session. [Fact] public async Task StreamEventsAsync_WhenWorkerEventStreamFaults_PropagatesTerminalFault() { @@ -359,15 +368,19 @@ public sealed class EventStreamServiceTests } } + /// Fake session manager for testing event streams. private sealed class FakeSessionManager : ISessionManager { private readonly IReadOnlyDictionary _sessions; + /// Initializes a new instance of the FakeSessionManager. + /// Sessions to manage. public FakeSessionManager(params GatewaySession[] sessions) { _sessions = sessions.ToDictionary(session => session.SessionId, StringComparer.Ordinal); } + /// public Task OpenSessionAsync( SessionOpenRequest request, string? clientIdentity, @@ -376,6 +389,7 @@ public sealed class EventStreamServiceTests return Task.FromResult(_sessions.Values.First()); } + /// public bool TryGetSession( string sessionId, out GatewaySession gatewaySession) @@ -383,6 +397,7 @@ public sealed class EventStreamServiceTests return _sessions.TryGetValue(sessionId, out gatewaySession!); } + /// public Task InvokeAsync( string sessionId, WorkerCommand command, @@ -391,6 +406,7 @@ public sealed class EventStreamServiceTests return Task.FromResult(new WorkerCommandReply()); } + /// public IAsyncEnumerable ReadEventsAsync( string sessionId, CancellationToken cancellationToken) @@ -398,6 +414,7 @@ public sealed class EventStreamServiceTests return _sessions[sessionId].ReadEventsAsync(cancellationToken); } + /// public Task CloseSessionAsync( string sessionId, CancellationToken cancellationToken) @@ -405,6 +422,7 @@ public sealed class EventStreamServiceTests return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); } + /// public Task CloseExpiredLeasesAsync( DateTimeOffset now, CancellationToken cancellationToken) @@ -412,33 +430,44 @@ public sealed class EventStreamServiceTests return Task.FromResult(0); } + /// public Task ShutdownAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } } + /// Fake worker client for testing event streams. private sealed class FakeWorkerClient : IWorkerClient { + /// Gets the list of queued worker events. public List Events { get; } = []; + /// Gets or sets whether to complete the event stream after configured events are yielded. public bool CompleteAfterConfiguredEvents { get; set; } + /// Gets or sets an optional exception to throw as a terminal event stream fault. public Exception? TerminalException { get; init; } + /// public string SessionId { get; } = "session-events"; + /// public int? ProcessId { get; } = 4321; + /// public WorkerClientState State { get; private set; } = WorkerClientState.Ready; + /// public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow; + /// public Task StartAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } + /// public Task InvokeAsync( WorkerCommand command, TimeSpan timeout, @@ -447,6 +476,7 @@ public sealed class EventStreamServiceTests return Task.FromResult(new WorkerCommandReply()); } + /// public async IAsyncEnumerable ReadEventsAsync( [EnumeratorCancellation] CancellationToken cancellationToken) { @@ -469,6 +499,7 @@ public sealed class EventStreamServiceTests await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); } + /// public Task ShutdownAsync( TimeSpan timeout, CancellationToken cancellationToken) @@ -477,11 +508,13 @@ public sealed class EventStreamServiceTests return Task.CompletedTask; } + /// public void Kill(string reason) { State = WorkerClientState.Faulted; } + /// public ValueTask DisposeAsync() { return ValueTask.CompletedTask; diff --git a/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs b/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs index 5739084..8d900c8 100644 --- a/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs +++ b/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs @@ -16,6 +16,7 @@ namespace MxGateway.Tests.Gateway.Grpc; public sealed class MxAccessGatewayServiceTests { + /// Verifies that OpenSession returns correct session details for a valid request. [Fact] public async Task OpenSession_WithValidRequest_ReturnsSessionDetails() { @@ -46,6 +47,7 @@ public sealed class MxAccessGatewayServiceTests Assert.Equal("operator-session", sessionManager.LastOpenRequest?.ClientSessionName); } + /// Verifies that Invoke throws NotFound when the session does not exist. [Fact] public async Task Invoke_WhenSessionMissing_ThrowsNotFound() { @@ -66,6 +68,7 @@ public sealed class MxAccessGatewayServiceTests Assert.Contains("session-missing", exception.Status.Detail, StringComparison.Ordinal); } + /// Verifies that Invoke throws InvalidArgument and does not invoke the session manager when payload is mismatched. [Fact] public async Task Invoke_WithMismatchedPayload_ThrowsInvalidArgumentAndDoesNotCallSessionManager() { @@ -88,6 +91,7 @@ public sealed class MxAccessGatewayServiceTests Assert.Equal(0, sessionManager.InvokeCount); } + /// Verifies that Invoke returns HResult status and method payload from worker reply. [Fact] public async Task Invoke_WithWorkerReply_ReturnsHresultStatusAndMethodPayload() { @@ -142,6 +146,7 @@ public sealed class MxAccessGatewayServiceTests Assert.Equal("mxaccess diagnostic", reply.DiagnosticMessage); } + /// Verifies that StreamEvents writes only events after the specified worker sequence. [Fact] public async Task StreamEvents_WithAfterSequence_WritesOnlyLaterEvents() { @@ -165,6 +170,7 @@ public sealed class MxAccessGatewayServiceTests Assert.Equal("session-1", sessionManager.LastReadEventsSessionId); } + /// Verifies that StreamEvents records send duration metrics when an event is written. [Fact] public async Task StreamEvents_WhenEventIsWritten_RecordsSendDuration() { @@ -209,6 +215,7 @@ public sealed class MxAccessGatewayServiceTests Assert.Equal([MxEventFamily.OnDataChange.ToString()], families); } + /// Verifies that CloseSession throws InvalidArgument when session ID is blank. [Fact] public async Task CloseSession_WithBlankSessionId_ThrowsInvalidArgument() { @@ -299,16 +306,22 @@ public sealed class MxAccessGatewayServiceTests private sealed class FakeSessionManager : ISessionManager { + /// The session to return from OpenSessionAsync. public GatewaySession? OpenSessionResult { get; init; } + /// The last OpenSessionAsync request captured. public SessionOpenRequest? LastOpenRequest { get; private set; } + /// The last client identity passed to OpenSessionAsync. public string? LastClientIdentity { get; private set; } + /// The last session ID passed to ReadEventsAsync. public string? LastReadEventsSessionId { get; private set; } + /// The last worker command passed to InvokeAsync. public WorkerCommand? LastWorkerCommand { get; private set; } + /// The reply to return from InvokeAsync. public WorkerCommandReply InvokeReply { get; init; } = new() { Reply = new MxCommandReply @@ -319,17 +332,23 @@ public sealed class MxAccessGatewayServiceTests }, }; + /// The exception to throw from InvokeAsync. public Exception? InvokeException { get; init; } + /// The number of times InvokeAsync was called. public int InvokeCount { get; private set; } + /// The events to return from ReadEventsAsync. public List Events { get; } = []; + /// Records the session ID passed to ReadEventsAsync. + /// Identifier of the session. public void RecordReadEventsSessionId(string sessionId) { LastReadEventsSessionId = sessionId; } + /// public Task OpenSessionAsync( SessionOpenRequest request, string? clientIdentity, @@ -341,6 +360,7 @@ public sealed class MxAccessGatewayServiceTests return Task.FromResult(OpenSessionResult ?? CreateSession("session-1", processId: 1234)); } + /// public bool TryGetSession( string sessionId, out GatewaySession session) @@ -349,6 +369,7 @@ public sealed class MxAccessGatewayServiceTests return true; } + /// public Task InvokeAsync( string sessionId, WorkerCommand command, @@ -365,6 +386,7 @@ public sealed class MxAccessGatewayServiceTests return Task.FromResult(InvokeReply); } + /// public async IAsyncEnumerable ReadEventsAsync( string sessionId, [EnumeratorCancellation] CancellationToken cancellationToken) @@ -378,6 +400,7 @@ public sealed class MxAccessGatewayServiceTests } } + /// public Task CloseSessionAsync( string sessionId, CancellationToken cancellationToken) @@ -385,6 +408,7 @@ public sealed class MxAccessGatewayServiceTests return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); } + /// public Task CloseExpiredLeasesAsync( DateTimeOffset now, CancellationToken cancellationToken) @@ -392,6 +416,7 @@ public sealed class MxAccessGatewayServiceTests return Task.FromResult(0); } + /// public Task ShutdownAsync(CancellationToken cancellationToken) { return Task.CompletedTask; @@ -400,6 +425,7 @@ public sealed class MxAccessGatewayServiceTests private sealed class FakeEventStreamService(FakeSessionManager sessionManager) : IEventStreamService { + /// public async IAsyncEnumerable StreamEventsAsync( StreamEventsRequest request, [EnumeratorCancellation] CancellationToken cancellationToken) @@ -421,19 +447,25 @@ public sealed class MxAccessGatewayServiceTests private sealed class FakeWorkerClient(int processId) : IWorkerClient { + /// public string SessionId { get; } = "session-1"; + /// public int? ProcessId { get; } = processId; + /// public WorkerClientState State { get; } = WorkerClientState.Ready; + /// public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow; + /// public Task StartAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } + /// public Task InvokeAsync( WorkerCommand command, TimeSpan timeout, @@ -442,6 +474,7 @@ public sealed class MxAccessGatewayServiceTests return Task.FromResult(new WorkerCommandReply()); } + /// public async IAsyncEnumerable ReadEventsAsync( [EnumeratorCancellation] CancellationToken cancellationToken) { @@ -449,6 +482,7 @@ public sealed class MxAccessGatewayServiceTests yield break; } + /// public Task ShutdownAsync( TimeSpan timeout, CancellationToken cancellationToken) @@ -456,10 +490,12 @@ public sealed class MxAccessGatewayServiceTests return Task.CompletedTask; } + /// public void Kill(string reason) { } + /// public ValueTask DisposeAsync() { return ValueTask.CompletedTask; @@ -468,10 +504,13 @@ public sealed class MxAccessGatewayServiceTests private sealed class TestServerStreamWriter : IServerStreamWriter { + /// public List Messages { get; } = []; + /// public WriteOptions? WriteOptions { get; set; } + /// public Task WriteAsync(T message) { Messages.Add(message); @@ -488,43 +527,56 @@ public sealed class MxAccessGatewayServiceTests 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) { diff --git a/src/MxGateway.Tests/Gateway/Grpc/MxAccessGrpcMapperTests.cs b/src/MxGateway.Tests/Gateway/Grpc/MxAccessGrpcMapperTests.cs index cc649ba..a6476d3 100644 --- a/src/MxGateway.Tests/Gateway/Grpc/MxAccessGrpcMapperTests.cs +++ b/src/MxGateway.Tests/Gateway/Grpc/MxAccessGrpcMapperTests.cs @@ -5,6 +5,7 @@ namespace MxGateway.Tests.Gateway.Grpc; public sealed class MxAccessGrpcMapperTests { + /// Verifies that command mapping clones payloads to isolate them across process boundaries. [Fact] public void MapCommand_ClonesMethodSpecificPayloadForWorkerBoundary() { @@ -37,6 +38,7 @@ public sealed class MxAccessGrpcMapperTests Assert.NotNull(workerCommand.EnqueueTimestamp); } + /// Verifies that command reply mapping preserves HRESULT and status information. [Fact] public void MapCommandReply_PreservesHresultStatusesAndPayload() { @@ -66,6 +68,7 @@ public sealed class MxAccessGrpcMapperTests Assert.Equal("denied", Assert.Single(publicReply.Statuses).DiagnosticText); } + /// Verifies that a missing worker reply returns a protocol violation status. [Fact] public void MapCommandReply_WhenWorkerReplyMissing_ReturnsProtocolViolationReply() { diff --git a/src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs b/src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs index fa5c90a..059716f 100644 --- a/src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs +++ b/src/MxGateway.Tests/Gateway/Sessions/SessionManagerTests.cs @@ -10,6 +10,7 @@ namespace MxGateway.Tests.Gateway.Sessions; public sealed class SessionManagerTests { + /// Verifies that opening a session with a ready worker registers the session in ready state. [Fact] public async Task OpenSessionAsync_WithWorkerReady_RegistersReadySession() { @@ -32,6 +33,7 @@ public sealed class SessionManagerTests Assert.Equal(1, metrics.GetSnapshot().SessionsOpened); } + /// Verifies that opening a session generates a correlation ID from the client name and session ID. [Fact] public async Task OpenSessionAsync_GeneratesClientCorrelationIdFromClientNameAndSessionId() { @@ -47,6 +49,7 @@ public sealed class SessionManagerTests Assert.Equal($"rust-load-client-{session.SessionId}", session.ClientCorrelationId); } + /// Verifies that opening a session without a client session name uses the client correlation prefix. [Fact] public async Task OpenSessionAsync_WhenClientSessionNameMissing_UsesClientCorrelationPrefix() { @@ -61,6 +64,7 @@ public sealed class SessionManagerTests Assert.Equal($"client-{session.SessionId}", session.ClientCorrelationId); } + /// Verifies that invoking a command on a ready session forwards the command to the worker. [Fact] public async Task InvokeAsync_WhenSessionReady_ForwardsCommandToWorker() { @@ -77,6 +81,7 @@ public sealed class SessionManagerTests Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind); } + /// Verifies that bulk subscribe forwards the command and returns subscription results. [Fact] public async Task GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults() { @@ -121,6 +126,7 @@ public sealed class SessionManagerTests Assert.Equal(["Galaxy.Tag.Value"], workerClient.LastCommand?.Command.SubscribeBulk.TagAddresses); } + /// Verifies that invoking a command on a faulted session rejects the command. [Fact] public async Task InvokeAsync_WhenSessionFaulted_RejectsCommand() { @@ -139,6 +145,7 @@ public sealed class SessionManagerTests Assert.Equal(0, workerClient.InvokeCount); } + /// Verifies that closing a session removes it from the registry. [Fact] public async Task CloseSessionAsync_RemovesClosedSession() { @@ -159,6 +166,7 @@ public sealed class SessionManagerTests Assert.Equal(0, metrics.GetSnapshot().OpenSessions); } + /// Verifies that closing a session kills the worker when shutdown fails. [Fact] public async Task CloseSessionAsync_WhenWorkerShutdownFails_KillsWorker() { @@ -179,6 +187,7 @@ public sealed class SessionManagerTests Assert.Equal(1, workerClient.KillCount); } + /// Verifies that when worker shutdown fails, the session is removed and the slot is released. [Fact] public async Task CloseSessionAsync_WhenWorkerShutdownFails_RemovesSessionAndReleasesSlot() { @@ -221,6 +230,7 @@ public sealed class SessionManagerTests Assert.Equal(1, snapshot.OpenSessions); } + /// Verifies that when the second close is canceled, the session is not removed if owned by the first close. [Fact] public async Task CloseSessionAsync_WhenSecondCloseIsCanceled_DoesNotRemoveSessionOwnedByFirstClose() { @@ -268,6 +278,7 @@ public sealed class SessionManagerTests Assert.Equal(0, metrics.GetSnapshot().OpenSessions); } + /// Verifies that when worker creation fails, the session is removed from the registry. [Fact] public async Task OpenSessionAsync_WhenWorkerCreationFails_RemovesSessionFromRegistry() { @@ -287,6 +298,7 @@ public sealed class SessionManagerTests Assert.Equal(1, metrics.GetSnapshot().Faults); } + /// Verifies that closing expired leases only closes expired sessions. [Fact] public async Task CloseExpiredLeasesAsync_ClosesExpiredSessionsOnly() { @@ -309,6 +321,7 @@ public sealed class SessionManagerTests Assert.Equal(0, activeClient.ShutdownCount); } + /// Verifies that shutdown closes all registered sessions. [Fact] public async Task ShutdownAsync_ClosesAllRegisteredSessions() { @@ -330,6 +343,12 @@ public sealed class SessionManagerTests Assert.Equal(0, metrics.GetSnapshot().OpenSessions); } + /// Creates a session manager for testing. + /// Worker client factory. + /// Session registry; defaults to a new registry. + /// Metrics collector; defaults to a new instance. + /// Gateway options; defaults to test defaults. + /// Configured session manager. private static SessionManager CreateManager( ISessionWorkerClientFactory factory, ISessionRegistry? registry = null, @@ -382,10 +401,13 @@ public sealed class SessionManagerTests private sealed class FakeSessionWorkerClientFactory(IWorkerClient workerClient) : ISessionWorkerClientFactory { + /// Gets the list of observed session states during worker creation. public List ObservedStates { get; } = []; + /// Gets or sets a value indicating whether to apply lifecycle transitions during worker creation. public bool ApplyLifecycleTransitions { get; init; } + /// public Task CreateAsync( GatewaySession session, CancellationToken cancellationToken) @@ -409,11 +431,14 @@ public sealed class SessionManagerTests { private readonly Queue _workerClients; + /// Initializes a new instance of the class. + /// Array of worker clients to queue. public QueueingSessionWorkerClientFactory(params IWorkerClient[] workerClients) { _workerClients = new Queue(workerClients); } + /// public Task CreateAsync( GatewaySession session, CancellationToken cancellationToken) @@ -424,6 +449,7 @@ public sealed class SessionManagerTests private sealed class FailingSessionWorkerClientFactory : ISessionWorkerClientFactory { + /// public Task CreateAsync( GatewaySession session, CancellationToken cancellationToken) @@ -434,39 +460,53 @@ public sealed class SessionManagerTests private sealed class FakeWorkerClient : IWorkerClient { + /// Gets the session ID for the fake worker client. public string SessionId { get; init; } = "session-1"; + /// Gets the process ID for the fake worker client. public int? ProcessId { get; init; } = 1234; + /// Gets or sets the state of the fake worker client. public WorkerClientState State { get; set; } = WorkerClientState.Ready; + /// Gets the last heartbeat timestamp for the fake worker client. public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow; + /// Gets the number of times invoke was called on the fake worker client. public int InvokeCount { get; private set; } + /// Gets the number of times shutdown was called on the fake worker client. public int ShutdownCount { get; private set; } + /// Gets the number of times kill was called on the fake worker client. public int KillCount { get; private set; } + /// Gets the number of times dispose was called on the fake worker client. public int DisposeCount { get; private set; } + /// Gets the exception to throw when shutdown is called, if any. public Exception? ShutdownException { get; init; } + /// Gets a value indicating whether to block shutdown on the fake worker client. public bool BlockShutdown { get; init; } + /// Gets the last command invoked on the fake worker client. public WorkerCommand? LastCommand { get; private set; } + /// Gets the reply to return for invoke calls on the fake worker client. public WorkerCommandReply? InvokeReply { get; init; } private TaskCompletionSource ShutdownStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); private TaskCompletionSource ShutdownReleased { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + /// public Task StartAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } + /// public Task InvokeAsync( WorkerCommand command, TimeSpan timeout, @@ -492,6 +532,7 @@ public sealed class SessionManagerTests }); } + /// public async IAsyncEnumerable ReadEventsAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { @@ -499,6 +540,7 @@ public sealed class SessionManagerTests yield break; } + /// public async Task ShutdownAsync( TimeSpan timeout, CancellationToken cancellationToken) @@ -518,23 +560,27 @@ public sealed class SessionManagerTests State = WorkerClientState.Closed; } + /// public void Kill(string reason) { KillCount++; State = WorkerClientState.Faulted; } + /// public ValueTask DisposeAsync() { DisposeCount++; return ValueTask.CompletedTask; } + /// Waits for shutdown to start on the fake worker client. public Task WaitForShutdownStartAsync() { return ShutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); } + /// Releases the shutdown block on the fake worker client. public void ReleaseShutdown() { ShutdownReleased.TrySetResult(); diff --git a/src/MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs b/src/MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs index a3a422a..c9b132f 100644 --- a/src/MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs +++ b/src/MxGateway.Tests/Gateway/Sessions/SessionWorkerClientFactoryFakeWorkerTests.cs @@ -14,6 +14,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests { private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + /// Verifies that the factory creates a ready worker client with a scripted fake worker process. [Fact] public async Task CreateAsync_WithScriptedFakeWorker_ReturnsReadyClient() { @@ -46,6 +47,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code); } + /// Verifies that a failed fake worker startup throws a worker client exception. [Fact] public async Task CreateAsync_WhenFakeWorkerStartupFails_ThrowsWorkerClientException() { @@ -65,6 +67,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests Assert.True(launcher.Process.IsDisposed); } + /// Verifies that a worker that never sends ready times out and is killed. [Fact] public async Task CreateAsync_WhenFakeWorkerNeverSendsReady_TimesOutAndKillsWorker() { @@ -131,13 +134,17 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests }; } + /// Fake worker launcher that connects a scripted fake worker harness. private sealed class ScriptedFakeWorkerProcessLauncher : IWorkerProcessLauncher { + /// The fake process ID used by the scripted launcher. public const int ProcessId = 2468; private readonly FakeWorkerProcess _process = new(ProcessId); + /// Gets the connected fake worker harness. public FakeWorkerHarness? Harness { get; private set; } + /// public Task LaunchAsync( WorkerProcessLaunchRequest request, CancellationToken cancellationToken = default) @@ -161,10 +168,13 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests } } + /// Fake worker launcher that fails during startup with protocol version mismatch. private sealed class FailingStartupWorkerProcessLauncher : IWorkerProcessLauncher { + /// Gets the fake worker process. public FakeWorkerProcess Process { get; } = new(processId: 3579); + /// public Task LaunchAsync( WorkerProcessLaunchRequest request, CancellationToken cancellationToken = default) @@ -192,10 +202,13 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests } } + /// Fake worker launcher that never completes startup, simulating a hung worker. private sealed class NeverReadyWorkerProcessLauncher : IWorkerProcessLauncher { + /// Gets the fake worker process. public FakeWorkerProcess Process { get; } = new(processId: 4680); + /// public Task LaunchAsync( WorkerProcessLaunchRequest request, CancellationToken cancellationToken = default) @@ -232,18 +245,24 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests DateTimeOffset.UtcNow); } + /// Fake worker process for testing process lifecycle. private sealed class FakeWorkerProcess(int processId) : IWorkerProcess { private bool _disposed; + /// public int Id { get; } = processId; + /// Gets or sets a value indicating whether the process has exited. public bool HasExited { get; private set; } + /// Gets or sets the process exit code. public int? ExitCode { get; private set; } + /// Gets the number of times the Kill method was called. public int KillCount { get; private set; } + /// public ValueTask WaitForExitAsync(CancellationToken cancellationToken) { HasExited = true; @@ -251,6 +270,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests return ValueTask.CompletedTask; } + /// public void Kill(bool entireProcessTree) { KillCount++; @@ -258,11 +278,13 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests ExitCode = -1; } + /// public void Dispose() { _disposed = true; } + /// Gets a value indicating whether this process has been disposed. public bool IsDisposed => _disposed; } } diff --git a/src/MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs b/src/MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs index c9daa54..4a92174 100644 --- a/src/MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs +++ b/src/MxGateway.Tests/Gateway/Workers/FakeWorkerHarnessTests.cs @@ -9,6 +9,7 @@ public sealed class FakeWorkerHarnessTests { private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + /// Verifies that completing startup with hello and ready transitions the client to ready state. [Fact] public async Task CompleteStartupAsync_WithHelloAndReady_TransitionsClientToReady() { @@ -25,6 +26,7 @@ public sealed class FakeWorkerHarnessTests Assert.Equal(FakeWorkerHarness.DefaultWorkerProcessId, client.ProcessId); } + /// Verifies that a protocol version mismatch during startup fails the client. [Fact] public async Task StartAsync_WithProtocolMismatch_FailsStartup() { @@ -43,6 +45,7 @@ public sealed class FakeWorkerHarnessTests Assert.Equal(WorkerClientErrorCode.ProtocolViolation, exception.ErrorCode); } + /// Verifies that a scripted reply completes a pending command invocation. [Fact] public async Task InvokeAsync_WithScriptedReply_CompletesCommand() { @@ -64,6 +67,7 @@ public sealed class FakeWorkerHarnessTests Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code); } + /// Verifies that scripted events are yielded in order through the event stream. [Fact] public async Task ReadEventsAsync_WithScriptedEvents_YieldsOrderedEvents() { @@ -87,6 +91,7 @@ public sealed class FakeWorkerHarnessTests Assert.Equal(MxEventFamily.OperationComplete, events.Current.Event.Family); } + /// Verifies that a scripted fault from the worker faults the client. [Fact] public async Task ReadLoop_WithScriptedFault_FaultsClient() { @@ -105,6 +110,7 @@ public sealed class FakeWorkerHarnessTests Assert.Equal(WorkerClientState.Faulted, client.State); } + /// Verifies that sending a heartbeat updates the client heartbeat state. [Fact] public async Task SendHeartbeatAsync_UpdatesClientHeartbeatState() { @@ -124,6 +130,7 @@ public sealed class FakeWorkerHarnessTests Assert.Equal(WorkerClientState.Ready, client.State); } + /// Verifies that a hung worker times out pending command invocations. [Fact] public async Task InvokeAsync_WithHungWorker_TimesOutPendingCommand() { @@ -144,6 +151,7 @@ public sealed class FakeWorkerHarnessTests Assert.Equal(WorkerClientErrorCode.CommandTimeout, exception.ErrorCode); } + /// Verifies that a malformed frame in the read loop faults the client. [Fact] public async Task ReadLoop_WithMalformedFrame_FaultsClient() { @@ -160,6 +168,7 @@ public sealed class FakeWorkerHarnessTests Assert.Equal(WorkerClientState.Faulted, client.State); } + /// Verifies that a shutdown acknowledgment from the worker closes the client. [Fact] public async Task ShutdownAsync_WithShutdownAck_ClosesClient() { diff --git a/src/MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs b/src/MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs index c9e5883..2541c33 100644 --- a/src/MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs +++ b/src/MxGateway.Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs @@ -37,12 +37,21 @@ public sealed class FakeWorkerHarness : IAsyncDisposable _writer = new WorkerFrameWriter(_workerStream, frameOptions); } + /// Gets the session ID for the fake worker harness. public string SessionId { get; } + /// Gets the nonce for the fake worker harness. public string Nonce { get; } + /// Gets or sets the next worker sequence number. public ulong NextWorkerSequence { get; private set; } + /// Creates a connected pair of fake worker harness with gateway and worker pipes. + /// Identifier for the fake session. + /// Nonce for session validation. + /// Protocol version for frame communication. + /// Maximum message size in bytes. + /// Token to cancel the asynchronous operation. public static async Task CreateConnectedPairAsync( string sessionId = DefaultSessionId, string nonce = DefaultNonce, @@ -71,6 +80,13 @@ public sealed class FakeWorkerHarness : IAsyncDisposable new WorkerFrameProtocolOptions(sessionId, protocolVersion, maxMessageBytes)); } + /// Connects to an existing gateway pipe as a fake worker harness. + /// Identifier for the fake session. + /// Nonce for session validation. + /// Name of the named pipe to connect to. + /// Protocol version for frame communication. + /// Maximum message size in bytes. + /// Token to cancel the asynchronous operation. public static async Task ConnectToGatewayPipeAsync( string sessionId, string nonce, @@ -90,6 +106,11 @@ public sealed class FakeWorkerHarness : IAsyncDisposable new WorkerFrameProtocolOptions(sessionId, protocolVersion, maxMessageBytes)); } + /// Creates a worker client connected to the fake worker harness. + /// Configuration options for the worker client. + /// Gateway metrics collector. + /// Time provider for timestamps. + /// A configured worker client connected to this harness. public WorkerClient CreateClient( WorkerClientOptions? options = null, GatewayMetrics? metrics = null, @@ -109,6 +130,13 @@ public sealed class FakeWorkerHarness : IAsyncDisposable return new WorkerClient(connection, options, metrics, timeProvider); } + /// Completes the worker startup handshake by reading the gateway hello and sending worker hello and ready. + /// Process ID of the fake worker. + /// Version string of the fake worker. + /// MXAccess COM ProgID. + /// MXAccess COM CLSID. + /// Token to cancel the asynchronous operation. + /// The gateway hello envelope received during startup. public async Task CompleteStartupAsync( int workerProcessId = DefaultWorkerProcessId, string workerVersion = "fake-worker", @@ -135,11 +163,17 @@ public sealed class FakeWorkerHarness : IAsyncDisposable return gatewayHello; } + /// Reads the next gateway envelope from the worker stream. + /// Token to cancel the asynchronous operation. + /// The gateway envelope read from the stream. public async Task ReadGatewayEnvelopeAsync(CancellationToken cancellationToken = default) { return await _reader.ReadAsync(cancellationToken).ConfigureAwait(false); } + /// Reads the next command from the worker stream. + /// Token to cancel the asynchronous operation. + /// The command envelope read from the stream. public async Task ReadCommandAsync(CancellationToken cancellationToken = default) { WorkerEnvelope envelope = await ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false); @@ -151,6 +185,9 @@ public sealed class FakeWorkerHarness : IAsyncDisposable return envelope; } + /// Reads the next shutdown request from the worker stream. + /// Token to cancel the asynchronous operation. + /// The shutdown envelope read from the stream. public async Task ReadShutdownAsync(CancellationToken cancellationToken = default) { WorkerEnvelope envelope = await ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false); @@ -162,6 +199,12 @@ public sealed class FakeWorkerHarness : IAsyncDisposable return envelope; } + /// Sends a worker hello message to the gateway. + /// Process ID of the fake worker. + /// Version string of the fake worker. + /// Protocol version override. + /// Nonce override. + /// Token to cancel the asynchronous operation. public async Task SendWorkerHelloAsync( int workerProcessId = DefaultWorkerProcessId, string workerVersion = "fake-worker", @@ -182,6 +225,11 @@ public sealed class FakeWorkerHarness : IAsyncDisposable cancellationToken).ConfigureAwait(false); } + /// Sends a worker ready message to the gateway. + /// Process ID of the fake worker. + /// MXAccess COM ProgID. + /// MXAccess COM CLSID. + /// Token to cancel the asynchronous operation. public async Task SendWorkerReadyAsync( int workerProcessId = DefaultWorkerProcessId, string mxaccessProgid = "LMXProxy.LMXProxyServer.1", @@ -201,6 +249,12 @@ public sealed class FakeWorkerHarness : IAsyncDisposable cancellationToken).ConfigureAwait(false); } + /// Sends a reply to a command received from the gateway. + /// The command envelope to reply to. + /// Protocol status code for the reply. + /// Human-readable status message. + /// Optional callback to customize the reply. + /// Token to cancel the asynchronous operation. public async Task ReplyToCommandAsync( WorkerEnvelope commandEnvelope, ProtocolStatusCode statusCode = ProtocolStatusCode.Ok, @@ -238,6 +292,10 @@ public sealed class FakeWorkerHarness : IAsyncDisposable cancellationToken).ConfigureAwait(false); } + /// Emits an event to the gateway. + /// Family of the event to emit. + /// Token to cancel the asynchronous operation. + /// Optional callback to customize the event. public async Task EmitEventAsync( MxEventFamily family, CancellationToken cancellationToken = default, @@ -263,6 +321,10 @@ public sealed class FakeWorkerHarness : IAsyncDisposable cancellationToken).ConfigureAwait(false); } + /// Emits a fault message to the gateway. + /// Category of the fault. + /// Diagnostic message describing the fault. + /// Token to cancel the asynchronous operation. public async Task EmitFaultAsync( WorkerFaultCategory category, string diagnosticMessage, @@ -284,6 +346,10 @@ public sealed class FakeWorkerHarness : IAsyncDisposable cancellationToken).ConfigureAwait(false); } + /// Sends a heartbeat message to the gateway. + /// Current worker state. + /// Token to cancel the asynchronous operation. + /// Optional callback to customize the heartbeat. public async Task SendHeartbeatAsync( WorkerState state = WorkerState.Ready, CancellationToken cancellationToken = default, @@ -304,6 +370,9 @@ public sealed class FakeWorkerHarness : IAsyncDisposable cancellationToken).ConfigureAwait(false); } + /// Sends a shutdown acknowledgment message to the gateway. + /// Protocol status code for the acknowledgment. + /// Token to cancel the asynchronous operation. public async Task SendShutdownAckAsync( ProtocolStatusCode statusCode = ProtocolStatusCode.Ok, CancellationToken cancellationToken = default) @@ -322,6 +391,9 @@ public sealed class FakeWorkerHarness : IAsyncDisposable cancellationToken).ConfigureAwait(false); } + /// Writes a malformed payload directly to the worker stream. + /// Malformed payload bytes to write. + /// Token to cancel the asynchronous operation. public async Task WriteMalformedPayloadAsync( ReadOnlyMemory payload, CancellationToken cancellationToken = default) @@ -337,6 +409,9 @@ public sealed class FakeWorkerHarness : IAsyncDisposable await _workerStream.WriteAsync(payload, cancellationToken).ConfigureAwait(false); } + /// Writes an oversized frame header to the worker stream for testing frame size limits. + /// Length of the oversized payload in bytes. + /// Token to cancel the asynchronous operation. public async Task WriteOversizedFrameHeaderAsync( uint payloadLength, CancellationToken cancellationToken = default) @@ -354,6 +429,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable await _workerStream.WriteAsync(lengthPrefix, cancellationToken).ConfigureAwait(false); } + /// Disposes the worker-side stream. public async ValueTask DisposeWorkerSideAsync() { if (_workerSideDisposed) @@ -365,6 +441,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable _workerSideDisposed = true; } + /// public async ValueTask DisposeAsync() { await DisposeWorkerSideAsync().ConfigureAwait(false); diff --git a/src/MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs b/src/MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs index 2cc1c17..886bc79 100644 --- a/src/MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs +++ b/src/MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs @@ -14,6 +14,7 @@ public sealed class WorkerClientTests private const int WorkerProcessId = 4321; private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + /// Verifies that StartAsync enters ready state after receiving worker hello and ready messages. [Fact] public async Task StartAsync_WithWorkerHelloAndReady_EntersReadyState() { @@ -26,6 +27,7 @@ public sealed class WorkerClientTests Assert.Equal(WorkerProcessId, client.ProcessId); } + /// Verifies that InvokeAsync completes a pending command when a matching reply arrives. [Fact] public async Task InvokeAsync_WithMatchingReply_CompletesPendingCommand() { @@ -51,6 +53,7 @@ public sealed class WorkerClientTests Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind); } + /// Verifies that InvokeAsync ignores late replies and keeps the client ready. [Fact] public async Task InvokeAsync_WithLateReply_IgnoresLateReplyAndKeepsClientReady() { @@ -86,6 +89,7 @@ public sealed class WorkerClientTests Assert.Equal(MxCommandKind.GetWorkerInfo, reply.Reply.Kind); } + /// Verifies that ReadEventsAsync yields events in pipe order from the worker. [Fact] public async Task ReadEventsAsync_WithWorkerEvents_YieldsEventsInPipeOrder() { @@ -111,6 +115,7 @@ public sealed class WorkerClientTests Assert.Equal(MxEventFamily.OperationComplete, events.Current.Event.Family); } + /// Verifies that the read loop faults the client when the event queue overflows. [Fact] public async Task ReadLoop_WhenEventQueueOverflows_FaultsClient() { @@ -137,6 +142,7 @@ public sealed class WorkerClientTests Assert.Equal(WorkerClientState.Faulted, client.State); } + /// Verifies that the read loop faults the client when the pipe disconnects. [Fact] public async Task ReadLoop_WhenPipeDisconnects_FaultsClient() { @@ -153,6 +159,7 @@ public sealed class WorkerClientTests Assert.Equal(WorkerClientState.Faulted, client.State); } + /// Verifies that the read loop stops the running worker metric when the pipe disconnects. [Fact] public async Task ReadLoop_WhenPipeDisconnects_StopsRunningWorkerMetric() { @@ -175,6 +182,7 @@ public sealed class WorkerClientTests Assert.Equal(1, snapshot.WorkerExits); } + /// Verifies that DisposeAsync returns within a bounded timeout when the pipe read is blocked. [Fact] public async Task DisposeAsync_WhenPipeReadIsBlocked_ReturnsWithinBoundedTimeout() { @@ -191,6 +199,7 @@ public sealed class WorkerClientTests $"DisposeAsync took {elapsed.TotalMilliseconds:N0}ms."); } + /// Verifies that the read loop updates the last heartbeat and worker process when a heartbeat arrives. [Fact] public async Task ReadLoop_WhenHeartbeatArrives_UpdatesLastHeartbeatAndWorkerProcess() { @@ -209,6 +218,7 @@ public sealed class WorkerClientTests Assert.Equal(WorkerClientState.Ready, client.State); } + /// Verifies that the heartbeat monitor faults the client when the heartbeat expires. [Fact] public async Task HeartbeatMonitor_WhenHeartbeatExpires_FaultsClient() { @@ -393,12 +403,16 @@ public sealed class WorkerClientTests WorkerWriter = new WorkerFrameWriter(_workerStream, new WorkerFrameProtocolOptions(SessionId)); } + /// The gateway side of the named pipe connection. public NamedPipeServerStream GatewayStream { get; } + /// Frame reader for worker messages. public WorkerFrameReader WorkerReader { get; } + /// Frame writer for worker messages. public WorkerFrameWriter WorkerWriter { get; } + /// Creates a connected pipe pair for testing. public static async Task CreateAsync() { string pipeName = $"mxaccessgw-workerclient-tests-{Guid.NewGuid():N}"; @@ -421,6 +435,7 @@ public sealed class WorkerClientTests return new PipePair(gatewayStream, workerStream); } + /// Disposes the worker side of the pipe. public async ValueTask DisposeWorkerSideAsync() { if (_workerSideDisposed) @@ -432,6 +447,7 @@ public sealed class WorkerClientTests _workerSideDisposed = true; } + /// Disposes the duplex stream. public async ValueTask DisposeAsync() { await DisposeWorkerSideAsync(); diff --git a/src/MxGateway.Tests/Gateway/Workers/WorkerFrameProtocolTests.cs b/src/MxGateway.Tests/Gateway/Workers/WorkerFrameProtocolTests.cs index 3122404..afe6459 100644 --- a/src/MxGateway.Tests/Gateway/Workers/WorkerFrameProtocolTests.cs +++ b/src/MxGateway.Tests/Gateway/Workers/WorkerFrameProtocolTests.cs @@ -10,6 +10,7 @@ public sealed class WorkerFrameProtocolTests { private const string SessionId = "session-1"; + /// Verifies that writing and reading a valid envelope round-trips the frame correctly. [Fact] public async Task WriteAndReadAsync_WithValidEnvelope_RoundTripsFrame() { @@ -27,6 +28,7 @@ public sealed class WorkerFrameProtocolTests Assert.Equal(original, parsed); } + /// Verifies that reading a frame with partial reads reassembles the frame correctly. [Fact] public async Task ReadAsync_WithPartialReads_ReassemblesFrame() { @@ -42,6 +44,7 @@ public sealed class WorkerFrameProtocolTests Assert.True(stream.ReadCallCount > 2); } + /// Verifies that reading a frame with zero length throws a malformed length exception. [Fact] public async Task ReadAsync_WithZeroLengthFrame_ThrowsMalformedLength() { @@ -56,6 +59,7 @@ public sealed class WorkerFrameProtocolTests Assert.Equal(WorkerFrameProtocolErrorCode.MalformedLength, exception.ErrorCode); } + /// Verifies that reading a frame with oversized length throws before allocating the payload. [Fact] public async Task ReadAsync_WithOversizedLength_ThrowsBeforePayloadAllocation() { @@ -72,6 +76,7 @@ public sealed class WorkerFrameProtocolTests Assert.Equal(WorkerFrameProtocolErrorCode.MessageTooLarge, exception.ErrorCode); } + /// Verifies that reading a frame with wrong protocol version throws a protocol version mismatch exception. [Fact] public async Task ReadAsync_WithWrongProtocolVersion_ThrowsProtocolVersionMismatch() { @@ -88,6 +93,7 @@ public sealed class WorkerFrameProtocolTests Assert.Equal(WorkerFrameProtocolErrorCode.ProtocolVersionMismatch, exception.ErrorCode); } + /// Verifies that reading a frame with wrong session ID throws a session mismatch exception. [Fact] public async Task ReadAsync_WithWrongSessionId_ThrowsSessionMismatch() { @@ -104,6 +110,7 @@ public sealed class WorkerFrameProtocolTests Assert.Equal(WorkerFrameProtocolErrorCode.SessionMismatch, exception.ErrorCode); } + /// Verifies that reading a frame with malformed payload throws an invalid envelope exception. [Fact] public async Task ReadAsync_WithMalformedPayload_ThrowsInvalidEnvelope() { @@ -119,6 +126,7 @@ public sealed class WorkerFrameProtocolTests Assert.Equal(WorkerFrameProtocolErrorCode.InvalidEnvelope, exception.ErrorCode); } + /// Verifies that reading a frame with missing envelope body throws an invalid envelope exception. [Fact] public async Task ReadAsync_WithMissingEnvelopeBody_ThrowsInvalidEnvelope() { @@ -135,6 +143,7 @@ public sealed class WorkerFrameProtocolTests Assert.Equal(WorkerFrameProtocolErrorCode.InvalidEnvelope, exception.ErrorCode); } + /// Verifies that writing an oversized envelope throws a message too large exception. [Fact] public async Task WriteAsync_WithOversizedEnvelope_ThrowsMessageTooLarge() { @@ -186,6 +195,9 @@ public sealed class WorkerFrameProtocolTests { private readonly int _chunkSize; + /// Initializes a new instance of the class with chunked reads. + /// The buffer containing data to read. + /// The maximum number of bytes to read per operation. public ChunkedReadStream( byte[] buffer, int chunkSize) @@ -194,8 +206,10 @@ public sealed class WorkerFrameProtocolTests _chunkSize = chunkSize; } + /// Gets the number of read calls made to the stream. public int ReadCallCount { get; private set; } + /// public override ValueTask ReadAsync( Memory buffer, CancellationToken cancellationToken = default) diff --git a/src/MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs b/src/MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs index 12b5513..31f233b 100644 --- a/src/MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs +++ b/src/MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs @@ -13,6 +13,7 @@ public sealed class WorkerProcessLauncherTests private const string PipeName = "mxaccess-gateway-123-session-1"; private const string Nonce = "super-secret-nonce"; + /// Verifies that a valid worker executable starts with correct bootstrap arguments and nonce environment variable. [Fact] public async Task LaunchAsync_WithValidWorker_StartsProcessWithBootstrapArgumentsAndNonceEnvironment() { @@ -46,6 +47,7 @@ public sealed class WorkerProcessLauncherTests Assert.Equal(0, metrics.GetSnapshot().WorkersRunning); } + /// Verifies that a failed startup probe kills and disposes the worker process. [Fact] public async Task LaunchAsync_WhenStartupProbeFails_KillsAndDisposesWorker() { @@ -71,6 +73,7 @@ public sealed class WorkerProcessLauncherTests Assert.Equal(1, metrics.GetSnapshot().WorkerKills); } + /// Verifies that transient startup probe failures are retried without respawning the worker process. [Fact] public async Task LaunchAsync_WhenStartupProbeFailsTransiently_RetriesWithoutRespawningWorker() { @@ -97,6 +100,7 @@ public sealed class WorkerProcessLauncherTests Assert.Equal(1, snapshot.RetryAttemptsByArea["worker_startup"]); } + /// Verifies that a startup probe timeout kills and disposes the worker process. [Fact] public async Task LaunchAsync_WhenStartupTimesOut_KillsAndDisposesWorker() { @@ -121,6 +125,7 @@ public sealed class WorkerProcessLauncherTests Assert.Equal(1, metrics.GetSnapshot().WorkerKills); } + /// Verifies that a missing worker executable fails before attempting to start the process. [Fact] public async Task LaunchAsync_WhenExecutableDoesNotExist_FailsBeforeStartingProcess() { @@ -137,6 +142,7 @@ public sealed class WorkerProcessLauncherTests Assert.Null(processFactory.LastStartInfo); } + /// Verifies that a worker executable with mismatched architecture fails before attempting to start. [Fact] public async Task LaunchAsync_WhenExecutableArchitectureDoesNotMatch_FailsBeforeStartingProcess() { @@ -153,6 +159,7 @@ public sealed class WorkerProcessLauncherTests Assert.Null(processFactory.LastStartInfo); } + /// Verifies that a worker that has already exited fails and disposes without additional killing. [Fact] public async Task LaunchAsync_WhenWorkerAlreadyExited_FailsAndDisposesWorkerWithoutKill() { @@ -215,12 +222,16 @@ public sealed class WorkerProcessLauncherTests pipeReservation); } + /// Fake worker process factory for testing process launch logic. private sealed class FakeWorkerProcessFactory(IWorkerProcess process) : IWorkerProcessFactory { + /// Gets the most recent process start information. public ProcessStartInfo? LastStartInfo { get; private set; } + /// Gets the number of times the process factory has started a process. public int StartCount { get; private set; } + /// public IWorkerProcess Start(ProcessStartInfo startInfo) { StartCount++; @@ -229,23 +240,31 @@ public sealed class WorkerProcessLauncherTests } } + /// Fake worker process for testing process lifecycle and exit behavior. private sealed class FakeWorkerProcess(int processId) : IWorkerProcess { + /// public int Id { get; } = processId; + /// Gets or sets a value indicating whether the process has exited. public bool HasExited { get; set; } + /// Gets or sets the process exit code. public int? ExitCode { get; set; } + /// Gets a value indicating whether the Dispose method was called. public bool DisposeCalled { get; private set; } + /// Gets a value indicating whether the Kill method was called. public bool KillCalled { get; private set; } + /// public ValueTask WaitForExitAsync(CancellationToken cancellationToken) { return ValueTask.CompletedTask; } + /// public void Kill(bool entireProcessTree) { Assert.True(entireProcessTree); @@ -253,14 +272,17 @@ public sealed class WorkerProcessLauncherTests HasExited = true; } + /// public void Dispose() { DisposeCalled = true; } } + /// Fake startup probe that immediately succeeds. private sealed class SucceedingStartupProbe : IWorkerStartupProbe { + /// public Task WaitUntilReadyAsync( IWorkerProcess process, WorkerProcessLaunchRequest request, @@ -270,8 +292,10 @@ public sealed class WorkerProcessLauncherTests } } + /// Fake startup probe that always fails. private sealed class FailingStartupProbe : IWorkerStartupProbe { + /// public Task WaitUntilReadyAsync( IWorkerProcess process, WorkerProcessLaunchRequest request, @@ -281,8 +305,10 @@ public sealed class WorkerProcessLauncherTests } } + /// Fake startup probe that waits indefinitely to simulate a startup timeout. private sealed class WaitingStartupProbe : IWorkerStartupProbe { + /// public async Task WaitUntilReadyAsync( IWorkerProcess process, WorkerProcessLaunchRequest request, @@ -292,10 +318,12 @@ public sealed class WorkerProcessLauncherTests } } + /// Fake startup probe that fails a configurable number of times before succeeding. private sealed class TransientStartupProbe(int failuresBeforeSuccess) : IWorkerStartupProbe { private int _attempts; + /// public Task WaitUntilReadyAsync( IWorkerProcess process, WorkerProcessLaunchRequest request, @@ -310,16 +338,20 @@ public sealed class WorkerProcessLauncherTests } } + /// Fake pipe reservation for testing pipe lifecycle. private sealed class FakePipeReservation : IDisposable { + /// Gets a value indicating whether the Dispose method was called. public bool DisposeCalled { get; private set; } + /// public void Dispose() { DisposeCalled = true; } } + /// Test helper that creates and cleans up a temporary directory for worker executable tests. private sealed class TestDirectory : IDisposable { private TestDirectory(string path) @@ -327,8 +359,10 @@ public sealed class WorkerProcessLauncherTests Path = path; } + /// Gets the path to the temporary test directory. public string Path { get; } + /// Creates a new temporary directory for testing. public static TestDirectory Create() { string path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"mxgateway-tests-{Guid.NewGuid():N}"); @@ -337,6 +371,9 @@ public sealed class WorkerProcessLauncherTests return new TestDirectory(path); } + /// Creates a fake PE executable with the specified machine architecture for testing. + /// PE machine type constant (0x014c for x86, 0x8664 for x64). + /// Full path to the created executable file. public string CreateWorkerExecutable(ushort machine) { string path = System.IO.Path.Combine(Path, "MxGateway.Worker.exe"); @@ -354,6 +391,7 @@ public sealed class WorkerProcessLauncherTests return path; } + /// public void Dispose() { Directory.Delete(Path, recursive: true); diff --git a/src/MxGateway.Tests/Metrics/GatewayMetricsTests.cs b/src/MxGateway.Tests/Metrics/GatewayMetricsTests.cs index fc7e221..d6d473a 100644 --- a/src/MxGateway.Tests/Metrics/GatewayMetricsTests.cs +++ b/src/MxGateway.Tests/Metrics/GatewayMetricsTests.cs @@ -4,6 +4,7 @@ namespace MxGateway.Tests.Metrics; public sealed class GatewayMetricsTests { + /// Verifies that snapshot reflects all metric updates. [Fact] public void GetSnapshot_ReflectsSessionWorkerCommandEventAndFaultUpdates() { @@ -50,6 +51,7 @@ public sealed class GatewayMetricsTests Assert.Equal(2, snapshot.EventsBySession["session-1"]); } + /// Verifies that negative queue depth is rejected. [Fact] public void SetEventQueueDepth_RejectsNegativeDepth() { @@ -61,6 +63,7 @@ public sealed class GatewayMetricsTests Assert.Equal("depth", exception.ParamName); } + /// Verifies that removing session events only affects that session. [Fact] public void RemoveSessionEvents_RemovesOnlyThatSession() { diff --git a/src/MxGateway.Tests/ProjectStructure/GatewayProjectReferenceTests.cs b/src/MxGateway.Tests/ProjectStructure/GatewayProjectReferenceTests.cs index b9711e8..7cd4775 100644 --- a/src/MxGateway.Tests/ProjectStructure/GatewayProjectReferenceTests.cs +++ b/src/MxGateway.Tests/ProjectStructure/GatewayProjectReferenceTests.cs @@ -4,6 +4,7 @@ namespace MxGateway.Tests.ProjectStructure; public sealed class GatewayProjectReferenceTests { + /// Verifies that the gateway project targets .NET 10.0. [Fact] public void GatewayProject_TargetsNet10() { @@ -12,6 +13,7 @@ public sealed class GatewayProjectReferenceTests Assert.Equal("net10.0", ElementValue(project, "TargetFramework")); } + /// Verifies that the gateway project does not reference MXAccess COM. [Fact] public void GatewayProject_DoesNotReferenceMxAccessCom() { diff --git a/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs b/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs index c924a29..fc734ba 100644 --- a/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs +++ b/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs @@ -8,6 +8,7 @@ namespace MxGateway.Tests.Security.Authentication; public sealed class ApiKeyAdminCliRunnerTests { + /// Verifies that CreateKeyAsync creates an authenticating key and audits the action. [Fact] public async Task CreateKeyAsync_CreatesAuthenticatingKeyAndAudits() { @@ -44,6 +45,7 @@ public sealed class ApiKeyAdminCliRunnerTests Assert.Contains(auditRecords, record => record.EventType == "create-key" && record.KeyId == "operator01"); } + /// Verifies that ListKeysAsync does not print the raw secret. [Fact] public async Task ListKeysAsync_DoesNotPrintRawSecret() { @@ -72,6 +74,7 @@ public sealed class ApiKeyAdminCliRunnerTests Assert.DoesNotContain("secret_hash", listJson, StringComparison.OrdinalIgnoreCase); } + /// Verifies that RevokeKeyAsync causes the revoked key to fail verification and is audited. [Fact] public async Task RevokeKeyAsync_RevokedKeyFailsVerificationAndAudits() { @@ -105,6 +108,7 @@ public sealed class ApiKeyAdminCliRunnerTests Assert.Contains(auditRecords, record => record.EventType == "revoke-key" && record.KeyId == "operator01"); } + /// Verifies that RotateKeyAsync prints the new secret once and invalidates the old secret. [Fact] public async Task RotateKeyAsync_PrintsNewSecretOnceAndInvalidatesOldSecret() { @@ -140,6 +144,7 @@ public sealed class ApiKeyAdminCliRunnerTests Assert.True(newVerification.Succeeded); } + /// Verifies that CreateKeyAsync prints the raw secret exactly once. [Fact] public async Task CreateKeyAsync_PrintsRawSecretExactlyOnce() { diff --git a/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs b/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs index a7d67e6..5791c7a 100644 --- a/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs +++ b/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs @@ -4,6 +4,9 @@ namespace MxGateway.Tests.Security.Authentication; public sealed class ApiKeyAdminCommandLineParserTests { + /// + /// Verifies non-API key commands return not-an-API-key result. + /// [Fact] public void Parse_NonApiKeyCommand_ReturnsNotApiKeyCommand() { @@ -13,6 +16,9 @@ public sealed class ApiKeyAdminCommandLineParserTests Assert.Null(result.Command); } + /// + /// Verifies API key create command parsing returns options. + /// [Fact] public void Parse_CreateKeyCommand_ReturnsOptions() { @@ -46,6 +52,9 @@ public sealed class ApiKeyAdminCommandLineParserTests Assert.Contains("events:read", result.Command.Scopes); } + /// + /// Verifies create key without display name returns error. + /// [Fact] public void Parse_CreateKeyWithoutDisplayName_ReturnsError() { @@ -57,6 +66,9 @@ public sealed class ApiKeyAdminCommandLineParserTests Assert.Contains("--display-name", result.Error, StringComparison.Ordinal); } + /// + /// Verifies key ID with underscore returns error. + /// [Fact] public void Parse_KeyIdWithUnderscore_ReturnsError() { diff --git a/src/MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs b/src/MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs index 2ca10d0..b4ef124 100644 --- a/src/MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs +++ b/src/MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs @@ -4,6 +4,7 @@ namespace MxGateway.Tests.Security.Authentication; public sealed class ApiKeyParserTests { + /// Verifies that TryParseAuthorizationHeader parses a valid Bearer token and returns the key ID and secret. [Fact] public void TryParseAuthorizationHeader_ValidBearerToken_ReturnsKeyIdAndSecret() { @@ -19,6 +20,8 @@ public sealed class ApiKeyParserTests Assert.Equal("secret_value", apiKey.Secret); } + /// Verifies that TryParseAuthorizationHeader returns false for malformed tokens. + /// Malformed authorization header value. [Theory] [InlineData(null)] [InlineData("")] diff --git a/src/MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs b/src/MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs index c386ff4..0577673 100644 --- a/src/MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs +++ b/src/MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs @@ -7,6 +7,9 @@ namespace MxGateway.Tests.Security.Authentication; public sealed class ApiKeySecretHasherTests { + /// + /// Verifies identical pepper and secret produce identical hashes. + /// [Fact] public void HashSecret_SamePepperAndSecret_ReturnsSameHash() { @@ -19,6 +22,9 @@ public sealed class ApiKeySecretHasherTests Assert.NotEqual("raw-secret"u8.ToArray(), firstHash); } + /// + /// Verifies different pepper values produce different hashes. + /// [Fact] public void HashSecret_DifferentPepper_ReturnsDifferentHash() { @@ -28,6 +34,9 @@ public sealed class ApiKeySecretHasherTests Assert.NotEqual(firstHash, secondHash); } + /// + /// Verifies missing pepper throws an exception. + /// [Fact] public void HashSecret_MissingPepper_Throws() { diff --git a/src/MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs b/src/MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs index afe0dac..6f90a09 100644 --- a/src/MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs +++ b/src/MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs @@ -8,6 +8,7 @@ namespace MxGateway.Tests.Security.Authentication; public sealed class ApiKeyVerifierTests { + /// Verifies that VerifyAsync returns identity and scopes for a valid key. [Fact] public async Task VerifyAsync_ValidKey_ReturnsIdentityAndScopes() { @@ -28,6 +29,7 @@ public sealed class ApiKeyVerifierTests Assert.True(store.MarkedUsed); } + /// Verifies that VerifyAsync does not expose the raw secret in the result. [Fact] public async Task VerifyAsync_ValidKey_DoesNotExposeRawSecretInResult() { @@ -44,6 +46,8 @@ public sealed class ApiKeyVerifierTests Assert.DoesNotContain("correct-secret", serialized, StringComparison.Ordinal); } + /// Verifies that VerifyAsync fails with unauthenticated status for a malformed key. + /// Authorization header value to test. [Theory] [InlineData(null)] [InlineData("Bearer mxgw_operator01")] @@ -63,6 +67,7 @@ public sealed class ApiKeyVerifierTests Assert.Equal(ApiKeyVerificationFailure.MissingOrMalformedCredentials, result.Failure); } + /// Verifies that VerifyAsync fails for an unknown key. [Fact] public async Task VerifyAsync_UnknownKey_Fails() { @@ -79,6 +84,7 @@ public sealed class ApiKeyVerifierTests Assert.Equal(ApiKeyVerificationFailure.KeyNotFound, result.Failure); } + /// Verifies that VerifyAsync fails for a wrong secret. [Fact] public async Task VerifyAsync_WrongSecret_Fails() { @@ -95,6 +101,7 @@ public sealed class ApiKeyVerifierTests Assert.False(store.MarkedUsed); } + /// Verifies that VerifyAsync fails for a revoked key. [Fact] public async Task VerifyAsync_RevokedKey_Fails() { @@ -111,6 +118,7 @@ public sealed class ApiKeyVerifierTests Assert.False(store.MarkedUsed); } + /// Verifies that VerifyAsync fails when the pepper is missing. [Fact] public async Task VerifyAsync_MissingPepper_Fails() { @@ -166,15 +174,23 @@ public sealed class ApiKeyVerifierTests return new ApiKeySecretHasher(configuration, Options.Create(options)); } + /// Fake in-memory API key store for testing. private sealed class FakeApiKeyStore(ApiKeyRecord? storedKey) : IApiKeyStore { + /// Gets whether the key was marked as used. public bool MarkedUsed { get; private set; } + /// Finds an API key record by its ID. + /// Identifier of the API key. + /// Token to cancel the asynchronous operation. public Task FindByKeyIdAsync(string keyId, CancellationToken cancellationToken) { return Task.FromResult(storedKey?.KeyId == keyId ? storedKey : null); } + /// Finds an active (non-revoked) API key record by its ID. + /// Identifier of the API key. + /// Token to cancel the asynchronous operation. public Task FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken) { return Task.FromResult( @@ -183,6 +199,10 @@ public sealed class ApiKeyVerifierTests : null); } + /// Marks an API key as used at the specified time. + /// Identifier of the API key. + /// Timestamp when the key was used. + /// Token to cancel the asynchronous operation. public Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken) { MarkedUsed = storedKey?.KeyId == keyId; diff --git a/src/MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs b/src/MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs index 7185532..7602cc3 100644 --- a/src/MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs +++ b/src/MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs @@ -8,8 +8,14 @@ using MxGateway.Server.Security.Authentication; namespace MxGateway.Tests.Security.Authentication; +/// +/// Tests for . +/// public sealed class SqliteAuthStoreTests { + /// + /// Verifies that MigrateAsync initializes the database schema. + /// [Fact] public async Task MigrateAsync_EmptyDatabase_InitializesCurrentSchema() { @@ -25,6 +31,9 @@ public sealed class SqliteAuthStoreTests Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeyAuditTable)); } + /// + /// Verifies that MigrateAsync migrates and is idempotent. + /// [Fact] public async Task MigrateAsync_ExistingVersionZeroDatabase_MigratesIdempotently() { @@ -42,6 +51,9 @@ public sealed class SqliteAuthStoreTests Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeyAuditTable)); } + /// + /// Verifies that gateway startup fails with a newer schema version. + /// [Fact] public async Task StartAsync_NewerSchemaVersion_BlocksStartup() { @@ -60,6 +72,9 @@ public sealed class SqliteAuthStoreTests Assert.Contains("newer than supported version", exception.Message, StringComparison.Ordinal); } + /// + /// Verifies that FindActiveByKeyIdAsync returns an active key. + /// [Fact] public async Task FindActiveByKeyIdAsync_ExistingActiveKey_ReturnsKey() { @@ -80,6 +95,9 @@ public sealed class SqliteAuthStoreTests Assert.Null(key.RevokedUtc); } + /// + /// Verifies that FindActiveByKeyIdAsync returns null for a revoked key. + /// [Fact] public async Task FindActiveByKeyIdAsync_RevokedKey_ReturnsNull() { @@ -100,6 +118,9 @@ public sealed class SqliteAuthStoreTests Assert.NotNull(storedKey.RevokedUtc); } + /// + /// Verifies that the audit store persists audit events. + /// [Fact] public async Task ApiKeyAuditStore_AppendAsync_PersistsAuditEvent() { diff --git a/src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs b/src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs index f75a807..718593c 100644 --- a/src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs +++ b/src/MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs @@ -9,6 +9,7 @@ namespace MxGateway.Tests.Security.Authorization; public sealed class GatewayGrpcAuthorizationInterceptorTests { + /// Verifies that missing API key returns unauthenticated status. [Fact] public async Task UnaryServerHandler_MissingApiKey_ReturnsUnauthenticated() { @@ -27,6 +28,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests Assert.DoesNotContain("secret", exception.Status.Detail, StringComparison.OrdinalIgnoreCase); } + /// Verifies that invalid API key error does not expose raw credentials. [Fact] public async Task UnaryServerHandler_InvalidApiKey_DoesNotExposeRawCredentialInStatus() { @@ -44,6 +46,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests Assert.DoesNotContain("super-secret", exception.Status.Detail, StringComparison.Ordinal); } + /// Verifies that valid key without required scope returns permission denied. [Fact] public async Task UnaryServerHandler_ValidApiKeyMissingScope_ReturnsPermissionDenied() { @@ -61,6 +64,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests Assert.Contains(GatewayScopes.SessionOpen, exception.Status.Detail, StringComparison.Ordinal); } + /// Verifies that valid key with scope sets request identity for the handler. [Fact] public async Task UnaryServerHandler_ValidApiKeyWithScope_SetsRequestIdentity() { @@ -86,6 +90,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests Assert.Null(identityAccessor.Current); } + /// Verifies that server stream handler requires proper scope. [Fact] public async Task ServerStreamingServerHandler_ValidApiKeyMissingScope_ReturnsPermissionDenied() { @@ -104,6 +109,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests Assert.Contains(GatewayScopes.EventsRead, exception.Status.Detail, StringComparison.Ordinal); } + /// Verifies that server stream handler allows streams with proper scope. [Fact] public async Task ServerStreamingServerHandler_ValidApiKeyWithScope_AllowsStream() { @@ -128,6 +134,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests Assert.Null(identityAccessor.Current); } + /// Verifies that disabled authentication skips API key verification. [Fact] public async Task UnaryServerHandler_AuthenticationDisabled_SkipsApiKeyVerification() { @@ -183,10 +190,16 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier { + /// Gets whether the verifier was called. public bool WasCalled { get; private set; } + /// Gets the last authorization header seen by the verifier. public string? LastAuthorizationHeader { get; private set; } + /// Verifies the authorization header against stored result. + /// The authorization header to verify. + /// Cancellation token. + /// Configured verification result. public Task VerifyAsync( string? authorizationHeader, CancellationToken cancellationToken) @@ -200,10 +213,15 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests private sealed class TestServerStreamWriter : IServerStreamWriter { + /// Gets messages written to the stream. public List Messages { get; } = []; + /// Gets or sets write options for the stream. public WriteOptions? WriteOptions { get; set; } + /// Writes a message to the stream. + /// The message to write. + /// Task representing the write operation. public Task WriteAsync(T message) { Messages.Add(message); @@ -221,43 +239,56 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests 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) { diff --git a/src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs b/src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs index efadef9..2df832c 100644 --- a/src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs +++ b/src/MxGateway.Tests/Security/Authorization/GatewayGrpcScopeResolverTests.cs @@ -6,6 +6,9 @@ namespace MxGateway.Tests.Security.Authorization; public sealed class GatewayGrpcScopeResolverTests { + /// Verifies that ResolveRequiredScope returns the expected scope for known RPC request types. + /// The gRPC request type to test. + /// The expected scope for the request. [Theory] [InlineData(typeof(OpenSessionRequest), GatewayScopes.SessionOpen)] [InlineData(typeof(CloseSessionRequest), GatewayScopes.SessionClose)] @@ -25,6 +28,9 @@ public sealed class GatewayGrpcScopeResolverTests Assert.Equal(expectedScope, scope); } + /// Verifies that ResolveRequiredScope returns the expected scope for MXAccess invoke commands. + /// The MXAccess command kind to test. + /// The expected scope for the command. [Theory] [InlineData(MxCommandKind.Register, GatewayScopes.InvokeRead)] [InlineData(MxCommandKind.AddItem, GatewayScopes.InvokeRead)] diff --git a/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerEnvironment.cs b/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerEnvironment.cs index 9b8cb10..75afd1e 100644 --- a/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerEnvironment.cs +++ b/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerEnvironment.cs @@ -4,25 +4,41 @@ using MxGateway.Worker.Bootstrap; namespace MxGateway.Worker.Tests.Bootstrap; +/// +/// In-memory test implementation of the worker environment. +/// internal sealed class MemoryWorkerEnvironment : IWorkerEnvironment { private readonly Dictionary _values = new(); private readonly Exception? _exception; + /// + /// Initializes an empty environment. + /// public MemoryWorkerEnvironment() { } + /// + /// Initializes an environment that throws when accessed. + /// + /// Exception to throw on access. public MemoryWorkerEnvironment(Exception exception) { _exception = exception; } + /// + /// Sets an environment variable in the in-memory store. + /// + /// Variable name. + /// Variable value. public void Set(string name, string value) { _values[name] = value; } + /// public string? GetEnvironmentVariable(string name) { if (_exception is not null) diff --git a/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogEntry.cs b/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogEntry.cs index 8f9c974..41e533d 100644 --- a/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogEntry.cs +++ b/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogEntry.cs @@ -2,8 +2,17 @@ using System.Collections.Generic; namespace MxGateway.Worker.Tests.Bootstrap; +/// +/// Captures a log entry for testing. +/// internal sealed class MemoryWorkerLogEntry { + /// + /// Initializes a log entry with level, event name, and fields. + /// + /// Log level (e.g., Debug, Info, Warning, Error). + /// Event name or category. + /// Dictionary of log fields. public MemoryWorkerLogEntry( string level, string eventName, @@ -14,9 +23,18 @@ internal sealed class MemoryWorkerLogEntry Fields = fields; } + /// + /// Gets the log level. + /// public string Level { get; } + /// + /// Gets the event name. + /// public string EventName { get; } + /// + /// Gets the log entry fields. + /// public IReadOnlyDictionary Fields { get; } } diff --git a/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogger.cs b/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogger.cs index 0b2e312..7af3a56 100644 --- a/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogger.cs +++ b/src/MxGateway.Worker.Tests/Bootstrap/MemoryWorkerLogger.cs @@ -3,15 +3,23 @@ using MxGateway.Worker.Bootstrap; namespace MxGateway.Worker.Tests.Bootstrap; +/// +/// In-memory logger that records all log entries for test inspection. +/// internal sealed class MemoryWorkerLogger : IWorkerLogger { + /// + /// All logged entries recorded in memory. + /// public List Entries { get; } = new(); + /// public void Information(string eventName, IReadOnlyDictionary fields) { Entries.Add(new MemoryWorkerLogEntry("Information", eventName, WorkerLogRedactor.RedactFields(fields))); } + /// public void Error(string eventName, IReadOnlyDictionary fields) { Entries.Add(new MemoryWorkerLogEntry("Error", eventName, WorkerLogRedactor.RedactFields(fields))); diff --git a/src/MxGateway.Worker.Tests/Bootstrap/WorkerApplicationTests.cs b/src/MxGateway.Worker.Tests/Bootstrap/WorkerApplicationTests.cs index cfda899..b43209b 100644 --- a/src/MxGateway.Worker.Tests/Bootstrap/WorkerApplicationTests.cs +++ b/src/MxGateway.Worker.Tests/Bootstrap/WorkerApplicationTests.cs @@ -9,6 +9,7 @@ namespace MxGateway.Worker.Tests.Bootstrap; public sealed class WorkerApplicationTests { + /// Verifies that valid bootstrap arguments succeed and redact nonce. [Fact] public void Run_WithValidBootstrapArguments_ReturnsSuccessAndLogsRedactedNonce() { @@ -33,6 +34,7 @@ public sealed class WorkerApplicationTests Assert.Equal("WorkerPipeSessionCompleted", logger.Entries[1].EventName); } + /// Verifies that missing arguments returns invalid arguments error. [Fact] public void Run_WithMissingRequiredArguments_ReturnsInvalidArguments() { @@ -51,6 +53,7 @@ public sealed class WorkerApplicationTests Assert.Equal(WorkerExitCode.InvalidArguments, entry.Fields["exit_code"]); } + /// Verifies that invalid protocol version is rejected. [Fact] public void Run_WithInvalidProtocolVersion_ReturnsInvalidProtocolVersion() { @@ -65,6 +68,7 @@ public sealed class WorkerApplicationTests Assert.Equal((int)WorkerExitCode.InvalidProtocolVersion, exitCode); } + /// Verifies that missing nonce is detected. [Fact] public void Run_WithMissingNonce_ReturnsMissingNonce() { @@ -79,6 +83,7 @@ public sealed class WorkerApplicationTests Assert.Equal((int)WorkerExitCode.MissingNonce, exitCode); } + /// Verifies that pipe protocol failure returns protocol violation error. [Fact] public void Run_WithPipeProtocolFailure_ReturnsProtocolViolation() { @@ -97,6 +102,7 @@ public sealed class WorkerApplicationTests Assert.Equal("WorkerPipeProtocolFailure", logger.Entries[1].EventName); } + /// Verifies that unexpected exceptions during bootstrap are logged. [Fact] public void Run_WithUnexpectedBootstrapFailure_ReturnsUnexpectedFailure() { @@ -137,6 +143,10 @@ public sealed class WorkerApplicationTests private sealed class SucceedingPipeClient : IWorkerPipeClient { + /// Runs the worker pipe client successfully. + /// Worker options. + /// Cancellation token. + /// Completed task. public Task RunAsync( WorkerOptions options, CancellationToken cancellationToken = default) @@ -149,11 +159,17 @@ public sealed class WorkerApplicationTests { private readonly Exception _exception; + /// Initializes the pipe client with an exception to throw. + /// Exception to throw when run. public ThrowingPipeClient(Exception exception) { _exception = exception; } + /// Runs the worker pipe client and throws configured exception. + /// Worker options. + /// Cancellation token. + /// Never completes; always throws. public Task RunAsync( WorkerOptions options, CancellationToken cancellationToken = default) diff --git a/src/MxGateway.Worker.Tests/Bootstrap/WorkerConsoleLoggerTests.cs b/src/MxGateway.Worker.Tests/Bootstrap/WorkerConsoleLoggerTests.cs index a74877b..611d1ed 100644 --- a/src/MxGateway.Worker.Tests/Bootstrap/WorkerConsoleLoggerTests.cs +++ b/src/MxGateway.Worker.Tests/Bootstrap/WorkerConsoleLoggerTests.cs @@ -6,6 +6,7 @@ namespace MxGateway.Worker.Tests.Bootstrap; public sealed class WorkerConsoleLoggerTests { + /// Verifies that console logger redacts nonce in structured output. [Fact] public void Information_RedactsNonceInStructuredOutput() { diff --git a/src/MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs b/src/MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs index d304cbe..ea2b68a 100644 --- a/src/MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs +++ b/src/MxGateway.Worker.Tests/Bootstrap/WorkerLogRedactorTests.cs @@ -5,6 +5,9 @@ namespace MxGateway.Worker.Tests.Bootstrap; public sealed class WorkerLogRedactorTests { + /// + /// Verifies sensitive fields are redacted in log dictionaries. + /// [Fact] public void RedactFields_RedactsNonceSecretPasswordTokenCredentialAndApiKeyFields() { diff --git a/src/MxGateway.Worker.Tests/Bootstrap/WorkerOptionsParserTests.cs b/src/MxGateway.Worker.Tests/Bootstrap/WorkerOptionsParserTests.cs index 4fa1749..12e6ad0 100644 --- a/src/MxGateway.Worker.Tests/Bootstrap/WorkerOptionsParserTests.cs +++ b/src/MxGateway.Worker.Tests/Bootstrap/WorkerOptionsParserTests.cs @@ -5,6 +5,7 @@ namespace MxGateway.Worker.Tests.Bootstrap; public sealed class WorkerOptionsParserTests { + /// Verifies that parsing with all required inputs returns worker options. [Fact] public void Parse_WithAllRequiredInputs_ReturnsWorkerOptions() { @@ -21,6 +22,7 @@ public sealed class WorkerOptionsParserTests Assert.Equal("nonce-secret", result.Options.Nonce); } + /// Verifies that parsing with missing session ID returns invalid arguments. [Fact] public void Parse_WithMissingSessionId_ReturnsInvalidArguments() { @@ -39,6 +41,7 @@ public sealed class WorkerOptionsParserTests Assert.Contains(result.Errors, error => error.Contains("--session-id")); } + /// Verifies that parsing with unknown option returns invalid arguments. [Fact] public void Parse_WithUnknownOption_ReturnsInvalidArguments() { @@ -60,6 +63,7 @@ public sealed class WorkerOptionsParserTests Assert.Contains(result.Errors, error => error.Contains("Unknown option")); } + /// Verifies that parsing with non-numeric protocol version returns invalid protocol version. [Fact] public void Parse_WithNonNumericProtocolVersion_ReturnsInvalidProtocolVersion() { @@ -71,6 +75,7 @@ public sealed class WorkerOptionsParserTests Assert.Equal(WorkerExitCode.InvalidProtocolVersion, result.ExitCode); } + /// Verifies that parsing with unsupported protocol version returns invalid protocol version. [Fact] public void Parse_WithUnsupportedProtocolVersion_ReturnsInvalidProtocolVersion() { @@ -82,6 +87,7 @@ public sealed class WorkerOptionsParserTests Assert.Equal(WorkerExitCode.InvalidProtocolVersion, result.ExitCode); } + /// Verifies that parsing with missing nonce returns missing nonce error. [Fact] public void Parse_WithMissingNonce_ReturnsMissingNonce() { diff --git a/src/MxGateway.Worker.Tests/Contracts/WorkerContractInfoTests.cs b/src/MxGateway.Worker.Tests/Contracts/WorkerContractInfoTests.cs index f94eb70..d24c4e1 100644 --- a/src/MxGateway.Worker.Tests/Contracts/WorkerContractInfoTests.cs +++ b/src/MxGateway.Worker.Tests/Contracts/WorkerContractInfoTests.cs @@ -5,12 +5,14 @@ namespace MxGateway.Worker.Tests.Contracts; public sealed class WorkerContractInfoTests { + /// Verifies that the supported protocol version matches the gateway contract version. [Fact] public void SupportedProtocolVersion_UsesSharedGatewayContractVersion() { Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, WorkerContractInfo.SupportedProtocolVersion); } + /// Verifies that the worker envelope descriptor name matches the generated protobuf contract. [Fact] public void WorkerEnvelopeDescriptorName_UsesGeneratedWorkerContract() { diff --git a/src/MxGateway.Worker.Tests/Conversion/HResultConverterTests.cs b/src/MxGateway.Worker.Tests/Conversion/HResultConverterTests.cs index 3873a98..de936e3 100644 --- a/src/MxGateway.Worker.Tests/Conversion/HResultConverterTests.cs +++ b/src/MxGateway.Worker.Tests/Conversion/HResultConverterTests.cs @@ -5,10 +5,16 @@ using MxGateway.Worker.Conversion; namespace MxGateway.Worker.Tests.Conversion; +/// +/// Tests for . +/// public sealed class HResultConverterTests { private readonly HResultConverter _converter = new(); + /// + /// Verifies that Convert captures the HResult from a COM exception. + /// [Fact] public void Convert_WithComException_CapturesExceptionHResult() { @@ -23,6 +29,9 @@ public sealed class HResultConverterTests Assert.DoesNotContain("Sensitive provider text", converted.DiagnosticMessage); } + /// + /// Verifies that CreateProtocolStatus returns OK for a success HResult. + /// [Fact] public void CreateProtocolStatus_WithSuccessHResult_ReturnsOk() { @@ -32,6 +41,9 @@ public sealed class HResultConverterTests Assert.Equal("HRESULT 0x00000000", status.Message); } + /// + /// Verifies that Convert captures the HResult from a non-COM exception. + /// [Fact] public void Convert_WithNonComException_CapturesExceptionHResult() { diff --git a/src/MxGateway.Worker.Tests/Conversion/MxStatusProxyConverterTests.cs b/src/MxGateway.Worker.Tests/Conversion/MxStatusProxyConverterTests.cs index da40034..7021f5e 100644 --- a/src/MxGateway.Worker.Tests/Conversion/MxStatusProxyConverterTests.cs +++ b/src/MxGateway.Worker.Tests/Conversion/MxStatusProxyConverterTests.cs @@ -9,6 +9,7 @@ public sealed class MxStatusProxyConverterTests { private readonly MxStatusProxyConverter _converter = new(); + /// Verifies that status struct fields are preserved during conversion. [Fact] public void Convert_WithStatusStruct_PreservesStatusFields() { @@ -31,6 +32,7 @@ public sealed class MxStatusProxyConverterTests Assert.Equal("Invalid reference", converted.DiagnosticText); } + /// Verifies that status arrays are converted without collapsing duplicate entries. [Fact] public void ConvertMany_WithStatusArray_DoesNotCollapseEntries() { @@ -61,6 +63,7 @@ public sealed class MxStatusProxyConverterTests Assert.Equal("Write access denied", converted[1].DiagnosticText); } + /// Verifies that unknown category and source values preserve raw field values. [Fact] public void Convert_WithUnknownCategoryAndSource_PreservesRawFields() { @@ -83,6 +86,7 @@ public sealed class MxStatusProxyConverterTests Assert.Equal(string.Empty, converted.DiagnosticText); } + /// Verifies that completion-only status bytes are preserved as hex metadata. [Fact] public void PreserveCompletionOnlyStatusBytes_ReturnsRawHexMetadata() { @@ -92,6 +96,7 @@ public sealed class MxStatusProxyConverterTests Assert.Equal("completion_only_status_hex=0000508000", rawStatus); } + /// Verifies that missing status fields throw a conversion exception. [Fact] public void Convert_WithMissingStatusField_ThrowsConversionException() { diff --git a/src/MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs b/src/MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs index 712687f..865548e 100644 --- a/src/MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs +++ b/src/MxGateway.Worker.Tests/Conversion/VariantConverterTests.cs @@ -11,6 +11,10 @@ public sealed class VariantConverterTests { private readonly VariantConverter _converter = new(); + /// Verifies that supported scalar types are converted with correct data type and value kind. + /// Scalar value to convert. + /// Expected MxDataType of the converted value. + /// Expected KindOneofCase of the converted value. [Theory] [InlineData(true, MxDataType.Boolean, MxValue.KindOneofCase.BoolValue)] [InlineData(42, MxDataType.Integer, MxValue.KindOneofCase.Int32Value)] @@ -30,6 +34,7 @@ public sealed class VariantConverterTests Assert.False(string.IsNullOrWhiteSpace(converted.VariantType)); } + /// Verifies that DateTime values are converted to protobuf timestamps. [Fact] public void Convert_WithDateTime_ProjectsTimestamp() { @@ -42,6 +47,7 @@ public sealed class VariantConverterTests Assert.Equal("VT_DATE", converted.VariantType); } + /// Verifies that file time values with expected time data type are converted to protobuf timestamps. [Fact] public void Convert_WithFileTimeAndExpectedTime_ProjectsTimestamp() { @@ -54,6 +60,9 @@ public sealed class VariantConverterTests Assert.Equal("VT_I8", converted.VariantType); } + /// Verifies that null-like values preserve their null semantics and variant type. + /// Null-like value to convert. + /// Expected variant type string. [Theory] [InlineData(null, "VT_EMPTY")] [InlineData(typeof(DBNull), "VT_NULL")] @@ -71,6 +80,7 @@ public sealed class VariantConverterTests Assert.Equal(MxValue.KindOneofCase.None, converted.KindCase); } + /// Verifies that supported array types are converted with correct element type and dimensions. [Fact] public void ConvertArray_WithSupportedArrays_ProjectsTypedValuesAndDimensions() { @@ -95,6 +105,7 @@ public sealed class VariantConverterTests Assert.Equal(MxDataType.Boolean, bools.ArrayValue.ElementDataType); } + /// Verifies that multidimensional arrays preserve rank and dimension information. [Fact] public void ConvertArray_WithMultidimensionalArray_PreservesRankAndDimensions() { @@ -110,6 +121,7 @@ public sealed class VariantConverterTests Assert.Equal(new[] { 1, 2, 3, 4, 5, 6 }, converted.ArrayValue.Int32Values.Values); } + /// Verifies that file time arrays with expected time data type are converted to timestamp arrays. [Fact] public void ConvertArray_WithExpectedTimeAndFileTimeValues_ProjectsTimestampArray() { @@ -126,6 +138,7 @@ public sealed class VariantConverterTests converted.ArrayValue.TimestampValues.Values); } + /// Verifies that unknown scalar types preserve raw value and diagnostic metadata. [Fact] public void Convert_WithUnknownScalar_PreservesRawMetadata() { @@ -140,6 +153,7 @@ public sealed class VariantConverterTests Assert.Equal(ByteString.CopyFromUtf8("opaque"), converted.RawValue); } + /// Verifies that unknown array types preserve raw values and diagnostic metadata. [Fact] public void ConvertArray_WithUnknownArray_PreservesRawMetadata() { @@ -158,6 +172,7 @@ public sealed class VariantConverterTests Assert.Contains(typeof(UnsupportedVariant).FullName!, converted.ArrayValue.RawDiagnostic); } + /// Verifies that credential-bearing fields are redacted before logging. [Fact] public void Redactor_WithCredentialBearingValueFields_RedactsBeforeLogging() { @@ -166,15 +181,19 @@ public sealed class VariantConverterTests Assert.Equal(WorkerLogRedactor.RedactedValue, WorkerLogRedactor.RedactValue("secured_write_token", "secret")); } + /// Fake unsupported variant type for testing unknown type handling. private sealed class UnsupportedVariant { private readonly string _value; + /// Initializes a new instance of the UnsupportedVariant class. + /// The opaque value. public UnsupportedVariant(string value) { _value = value; } + /// public override string ToString() { return _value; diff --git a/src/MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs b/src/MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs index 5cd977b..c128af9 100644 --- a/src/MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs +++ b/src/MxGateway.Worker.Tests/Ipc/WorkerFrameProtocolTests.cs @@ -14,6 +14,7 @@ public sealed class WorkerFrameProtocolTests private const string SessionId = "session-1"; private const string Nonce = "nonce-secret"; + /// Verifies that valid envelopes round-trip through write and read. [Fact] public async Task WriteAndReadAsync_WithValidEnvelope_RoundTripsFrame() { @@ -31,6 +32,7 @@ public sealed class WorkerFrameProtocolTests Assert.Equal(original, parsed); } + /// Verifies that wrong protocol version throws mismatch error. [Fact] public async Task ReadAsync_WithWrongProtocolVersion_ThrowsProtocolVersionMismatch() { @@ -47,6 +49,7 @@ public sealed class WorkerFrameProtocolTests Assert.Equal(WorkerFrameProtocolErrorCode.ProtocolVersionMismatch, exception.ErrorCode); } + /// Verifies that wrong session ID throws mismatch error. [Fact] public async Task ReadAsync_WithWrongSessionId_ThrowsSessionMismatch() { @@ -63,6 +66,7 @@ public sealed class WorkerFrameProtocolTests Assert.Equal(WorkerFrameProtocolErrorCode.SessionMismatch, exception.ErrorCode); } + /// Verifies that malformed length throws error. [Fact] public async Task ReadAsync_WithMalformedLength_ThrowsMalformedLength() { @@ -77,6 +81,7 @@ public sealed class WorkerFrameProtocolTests Assert.Equal(WorkerFrameProtocolErrorCode.MalformedLength, exception.ErrorCode); } + /// Verifies that malformed payload throws invalid envelope error. [Fact] public async Task ReadAsync_WithMalformedPayload_ThrowsInvalidEnvelope() { @@ -91,6 +96,7 @@ public sealed class WorkerFrameProtocolTests Assert.Equal(WorkerFrameProtocolErrorCode.InvalidEnvelope, exception.ErrorCode); } + /// Verifies that concurrent writes produce complete serialized frames. [Fact] public async Task WriteAsync_WithConcurrentCalls_SerializesCompleteFrames() { diff --git a/src/MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs b/src/MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs index 25e2ead..e3c45dd 100644 --- a/src/MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs +++ b/src/MxGateway.Worker.Tests/Ipc/WorkerPipeClientTests.cs @@ -16,6 +16,7 @@ namespace MxGateway.Worker.Tests.Ipc; public sealed class WorkerPipeClientTests { + /// Verifies that worker client connects and completes handshake. [Fact] public async Task RunAsync_ConnectsToPipeAndCompletesHandshake() { @@ -81,6 +82,7 @@ public sealed class WorkerPipeClientTests await clientTask; } + /// Verifies that worker client retries until pipe server becomes available. [Fact] public async Task RunAsync_RetriesUntilPipeServerAppears() { @@ -122,6 +124,7 @@ public sealed class WorkerPipeClientTests await clientTask; } + /// Verifies that worker client throws timeout if pipe never appears. [Fact] public async Task RunAsync_WhenPipeNeverAppears_ThrowsTimeoutException() { @@ -190,6 +193,11 @@ public sealed class WorkerPipeClientTests private sealed class FakeRuntimeSession : IWorkerRuntimeSession { + /// Starts the worker session. + /// Session ID. + /// Worker process ID. + /// Cancellation token. + /// Worker ready response. public Task StartAsync( string sessionId, int workerProcessId, @@ -204,6 +212,9 @@ public sealed class WorkerPipeClientTests }); } + /// Dispatches a command to STA thread. + /// The command. + /// Command reply. public Task DispatchAsync(StaCommand command) { return Task.FromResult(new MxCommandReply @@ -219,6 +230,8 @@ public sealed class WorkerPipeClientTests }); } + /// Captures current runtime heartbeat snapshot. + /// Heartbeat snapshot. public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat() { return new WorkerRuntimeHeartbeatSnapshot( @@ -229,25 +242,38 @@ public sealed class WorkerPipeClientTests currentCommandCorrelationId: string.Empty); } + /// Drains queued events. + /// Maximum events to drain. + /// Drained events. public IReadOnlyList DrainEvents(uint maxEvents) { return Array.Empty(); } + /// Drains pending fault if any. + /// Fault or null. public WorkerFault? DrainFault() { return null; } + /// Cancels a command by correlation ID. + /// Command correlation ID. + /// True if cancelled. public bool CancelCommand(string correlationId) { return false; } + /// Requests graceful shutdown. public void RequestShutdown() { } + /// Shuts down gracefully within timeout. + /// Shutdown timeout. + /// Cancellation token. + /// Shutdown result. public Task ShutdownGracefullyAsync( TimeSpan timeout, CancellationToken cancellationToken = default) @@ -255,6 +281,7 @@ public sealed class WorkerPipeClientTests return Task.FromResult(new MxAccessShutdownResult(Array.Empty())); } + /// Disposes resources. public void Dispose() { } diff --git a/src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs b/src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs index d8188cc..e65f1d1 100644 --- a/src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs +++ b/src/MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs @@ -19,6 +19,7 @@ public sealed class WorkerPipeSessionTests private const string SessionId = "session-1"; private const string Nonce = "nonce-secret"; + /// Verifies that valid gateway hello triggers worker hello and ready responses. [Fact] public async Task CompleteStartupHandshakeAsync_WithValidGatewayHello_SendsHelloThenReady() { @@ -49,6 +50,7 @@ public sealed class WorkerPipeSessionTests Assert.NotNull(written[1].WorkerReady.ReadyTimestamp); } + /// Verifies that wrong nonce causes protocol violation fault before initialization. [Fact] public async Task CompleteStartupHandshakeAsync_WithWrongNonce_FaultsBeforeInitialization() { @@ -76,6 +78,7 @@ public sealed class WorkerPipeSessionTests Assert.Equal(WorkerFaultCategory.ProtocolViolation, fault.WorkerFault.Category); } + /// Verifies that unsupported protocol version causes mismatch fault before initialization. [Fact] public async Task CompleteStartupHandshakeAsync_WithWrongProtocol_FaultsBeforeInitialization() { @@ -102,6 +105,7 @@ public sealed class WorkerPipeSessionTests Assert.Equal(WorkerFaultCategory.ProtocolMismatch, fault.WorkerFault.Category); } + /// Verifies that malformed frame causes protocol violation fault. [Fact] public async Task CompleteStartupHandshakeAsync_WithMalformedFrame_WritesWorkerFault() { @@ -127,6 +131,7 @@ public sealed class WorkerPipeSessionTests Assert.Equal(WorkerFaultCategory.ProtocolViolation, fault.WorkerFault.Category); } + /// Verifies that MXAccess COM creation failure produces fault instead of ready. [Fact] public async Task CompleteStartupHandshakeAsync_WhenMxAccessCreationFails_WritesFaultInsteadOfReady() { @@ -152,6 +157,7 @@ public sealed class WorkerPipeSessionTests Assert.Equal(ProtocolStatusCode.WorkerUnavailable, written[1].WorkerFault.ProtocolStatus.Code); } + /// Verifies that heartbeat payload reflects current runtime snapshot. [Fact] public async Task RunAsync_SendsHeartbeatPayloadFromRuntimeSnapshot() { @@ -192,6 +198,7 @@ public sealed class WorkerPipeSessionTests await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token); } + /// Verifies that heartbeat reports current command correlation during execution. [Fact] public async Task RunAsync_WhenCommandIsExecuting_HeartbeatReportsCurrentCorrelation() { @@ -238,6 +245,7 @@ public sealed class WorkerPipeSessionTests await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token); } + /// Verifies that worker events are written to the pipe. [Fact] public async Task RunAsync_WhenRuntimeHasEvents_WritesWorkerEventEnvelope() { @@ -269,6 +277,7 @@ public sealed class WorkerPipeSessionTests } + /// Verifies that stale STA activity triggers watchdog fault. [Fact] public async Task RunAsync_WhenStaActivityIsStale_WritesWatchdogFault() { @@ -304,6 +313,7 @@ public sealed class WorkerPipeSessionTests await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token); } + /// Verifies that shutdown drops late replies and sends shutdown ack. [Fact] public async Task RunAsync_WhenShutdownArrivesDuringCommand_DropsLateReplyAndWritesShutdownAck() { @@ -343,6 +353,7 @@ public sealed class WorkerPipeSessionTests await runTask; } + /// Verifies that command exceptions after shutdown are dropped before ack. [Fact] public async Task RunAsync_WhenCommandThrowsAfterShutdown_DropsLateFaultAndWritesShutdownAck() { @@ -534,6 +545,11 @@ public sealed class WorkerPipeSessionTests } } + /// Reads frames until one matching the expected body type is found. + /// Frame reader. + /// Expected body case. + /// Token to cancel the asynchronous operation. + /// The matching envelope. private static Task ReadUntilAsync( WorkerFrameReader reader, WorkerEnvelope.BodyOneofCase expectedBody, @@ -546,6 +562,12 @@ public sealed class WorkerPipeSessionTests cancellationToken); } + /// Reads frames until one matches the expected body type and predicate. + /// Frame reader. + /// Expected body case. + /// Predicate to match against envelope. + /// Token to cancel the asynchronous operation. + /// The matching envelope. private static async Task ReadUntilAsync( WorkerFrameReader reader, WorkerEnvelope.BodyOneofCase expectedBody, @@ -609,12 +631,20 @@ public sealed class WorkerPipeSessionTests lastEventSequence: 0, currentCommandCorrelationId: string.Empty); + /// Gets the event signaled when dispatch begins. public ManualResetEventSlim DispatchStarted { get; } = new(false); + /// Blocks dispatch execution until explicitly released. public bool BlockDispatch { get; set; } + /// Gets or sets whether to throw an exception after dispatch is released. public bool ThrowAfterDispatchReleased { get; set; } + /// Starts the worker session with the given session ID and process ID. + /// The session identifier. + /// The worker process ID. + /// Cancellation token. + /// Worker ready response. public Task StartAsync( string sessionId, int workerProcessId, @@ -629,6 +659,9 @@ public sealed class WorkerPipeSessionTests }); } + /// Dispatches a command to the STA thread. + /// The command to dispatch. + /// The command reply. public Task DispatchAsync(StaCommand command) { return Task.Run( @@ -673,6 +706,8 @@ public sealed class WorkerPipeSessionTests }); } + /// Captures current heartbeat snapshot. + /// Current runtime heartbeat snapshot. public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat() { lock (gate) @@ -681,6 +716,9 @@ public sealed class WorkerPipeSessionTests } } + /// Drains queued events up to the specified limit. + /// Maximum events to drain; 0 drains all. + /// The drained events. public IReadOnlyList DrainEvents(uint maxEvents) { lock (gate) @@ -698,21 +736,31 @@ public sealed class WorkerPipeSessionTests } } + /// Drains a pending fault if any. + /// Pending fault or null. public WorkerFault? DrainFault() { return null; } + /// Cancels command by correlation ID. + /// The command correlation ID. + /// True if cancelled; false otherwise. public bool CancelCommand(string correlationId) { return false; } + /// Requests graceful shutdown. public void RequestShutdown() { releaseDispatch.Set(); } + /// Shuts down gracefully within the specified timeout. + /// Shutdown timeout period. + /// Cancellation token. + /// Shutdown result. public Task ShutdownGracefullyAsync( TimeSpan timeout, CancellationToken cancellationToken = default) @@ -721,11 +769,14 @@ public sealed class WorkerPipeSessionTests return Task.FromResult(new MxAccessShutdownResult(Array.Empty())); } + /// Releases a blocked dispatch. public void ReleaseDispatch() { releaseDispatch.Set(); } + /// Sets the current heartbeat snapshot. + /// The snapshot to set. public void SetSnapshot(WorkerRuntimeHeartbeatSnapshot value) { lock (gate) @@ -734,6 +785,8 @@ public sealed class WorkerPipeSessionTests } } + /// Enqueues a worker event to be drained. + /// The event to enqueue. public void EnqueueEvent(WorkerEvent workerEvent) { lock (gate) @@ -742,6 +795,7 @@ public sealed class WorkerPipeSessionTests } } + /// Disposes resources. public void Dispose() { releaseDispatch.Set(); @@ -765,12 +819,18 @@ public sealed class WorkerPipeSessionTests GatewayWriter = new WorkerFrameWriter(gatewayStream, options); } + /// Gets the worker side of the named pipe stream. public Stream WorkerStream { get; } + /// Gets the gateway frame reader. public WorkerFrameReader GatewayReader { get; } + /// Gets the gateway frame writer. public WorkerFrameWriter GatewayWriter { get; } + /// Creates a connected pair of named pipes for testing. + /// Cancellation token. + /// Connected pipe pair. public static async Task CreateAsync(CancellationToken cancellationToken) { string pipeName = $"mxaccessgw-worker-session-tests-{Guid.NewGuid():N}"; @@ -795,6 +855,7 @@ public sealed class WorkerPipeSessionTests return new PipePair(gatewayStream, workerStream); } + /// Disposes pipe resources. public void Dispose() { WorkerStream.Dispose(); diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs b/src/MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs index 08a6a87..d98082c 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs @@ -11,6 +11,7 @@ namespace MxGateway.Worker.Tests.MxAccess; public sealed class MxAccessCommandExecutorTests { + /// Verifies that Register command calls MXAccess on the STA thread and preserves the server handle. [Fact] public async Task DispatchAsync_Register_CallsMxAccessOnStaAndPreservesServerHandle() { @@ -36,6 +37,7 @@ public sealed class MxAccessCommandExecutorTests Assert.Equal("client-a", registeredServerHandle.ClientName); } + /// Verifies that Unregister command calls MXAccess on the STA thread and removes the tracked server handle. [Fact] public async Task DispatchAsync_Unregister_CallsMxAccessOnStaAndRemovesTrackedServerHandle() { @@ -54,6 +56,7 @@ public sealed class MxAccessCommandExecutorTests Assert.Empty(await session.GetRegisteredServerHandlesAsync()); } + /// Verifies that Unregister preserves the HResult when MXAccess throws and does not rewrite the failure. [Fact] public async Task DispatchAsync_UnregisterWhenMxAccessThrows_PreservesHResultAndDoesNotRewriteFailure() { @@ -80,6 +83,7 @@ public sealed class MxAccessCommandExecutorTests Assert.Equal(44, registeredServerHandle.ServerHandle); } + /// Verifies that AddItem command calls MXAccess on the STA thread and tracks the item handle. [Fact] public async Task DispatchAsync_AddItem_CallsMxAccessOnStaAndTracksItemHandle() { @@ -116,6 +120,7 @@ public sealed class MxAccessCommandExecutorTests Assert.False(registeredItemHandle.HasItemContext); } + /// Verifies that AddItem2 command passes the context exactly and tracks the item handle. [Fact] public async Task DispatchAsync_AddItem2_PassesContextExactlyAndTracksItemHandle() { @@ -152,6 +157,7 @@ public sealed class MxAccessCommandExecutorTests Assert.True(registeredItemHandle.HasItemContext); } + /// Verifies that RemoveItem command calls MXAccess on the STA thread and removes the tracked item handle. [Fact] public async Task DispatchAsync_RemoveItem_CallsMxAccessOnStaAndRemovesTrackedItemHandle() { @@ -179,6 +185,7 @@ public sealed class MxAccessCommandExecutorTests Assert.Empty(await session.GetRegisteredItemHandlesAsync()); } + /// Verifies that RemoveItem removes tracked advice after MXAccess succeeds on an advised handle. [Fact] public async Task DispatchAsync_RemoveItemWithAdvisedHandle_RemovesTrackedAdviceAfterMxAccessSucceeds() { @@ -203,6 +210,7 @@ public sealed class MxAccessCommandExecutorTests Assert.Empty(await session.GetRegisteredAdviceHandlesAsync()); } + /// Verifies that RemoveItem preserves the HResult and keeps the tracked item handle when using a cross-server handle. [Fact] public async Task DispatchAsync_RemoveItemWithCrossServerHandle_PreservesHResultAndKeepsTrackedItemHandle() { @@ -236,6 +244,7 @@ public sealed class MxAccessCommandExecutorTests Assert.Equal(504, registeredItemHandle.ItemHandle); } + /// Verifies that AddItem2 preserves the HResult when MXAccess throws and does not track the item handle. [Fact] public async Task DispatchAsync_AddItem2WhenMxAccessThrows_PreservesHResultAndDoesNotTrackItemHandle() { @@ -264,6 +273,7 @@ public sealed class MxAccessCommandExecutorTests Assert.Empty(await session.GetRegisteredItemHandlesAsync()); } + /// Verifies that Advise command calls MXAccess on the STA thread and tracks plain advice. [Fact] public async Task DispatchAsync_Advise_CallsMxAccessOnStaAndTracksPlainAdvice() { @@ -296,6 +306,7 @@ public sealed class MxAccessCommandExecutorTests Assert.Equal(MxAccessAdviceKind.Plain, adviceHandle.AdviceKind); } + /// Verifies that AdviseSupervisory calls a distinct MXAccess method and tracks supervisory advice. [Fact] public async Task DispatchAsync_AdviseSupervisory_CallsDistinctMxAccessMethodAndTracksSupervisoryAdvice() { @@ -327,6 +338,7 @@ public sealed class MxAccessCommandExecutorTests Assert.Equal(MxAccessAdviceKind.Supervisory, adviceHandle.AdviceKind); } + /// Verifies that UnAdvise command calls MXAccess on the STA thread and removes the tracked advice. [Fact] public async Task DispatchAsync_UnAdvise_CallsMxAccessOnStaAndRemovesTrackedAdvice() { @@ -354,6 +366,7 @@ public sealed class MxAccessCommandExecutorTests Assert.Empty(await session.GetRegisteredAdviceHandlesAsync()); } + /// Verifies that Advise preserves the HResult when MXAccess throws and does not track the advice. [Fact] public async Task DispatchAsync_AdviseWhenMxAccessThrows_PreservesHResultAndDoesNotTrackAdvice() { @@ -383,6 +396,7 @@ public sealed class MxAccessCommandExecutorTests Assert.Empty(await session.GetRegisteredAdviceHandlesAsync()); } + /// Verifies that UnAdvise preserves the HResult when MXAccess throws and keeps the tracked advice. [Fact] public async Task DispatchAsync_UnAdviseWhenMxAccessThrows_PreservesHResultAndKeepsTrackedAdvice() { @@ -416,6 +430,7 @@ public sealed class MxAccessCommandExecutorTests Assert.Equal(MxAccessAdviceKind.Plain, adviceHandle.AdviceKind); } + /// Verifies that SubscribeBulk runs sequential MXAccess calls and returns per-item results. [Fact] public async Task DispatchAsync_SubscribeBulk_RunsSequentialMxAccessCallsAndReturnsPerItemResults() { @@ -456,6 +471,7 @@ public sealed class MxAccessCommandExecutorTests Assert.Equal(runtime.StaThreadId, fakeComObject.AdviseThreadId); } + /// Verifies that UnsubscribeBulk removes items after UnAdvise failure. [Fact] public async Task DispatchAsync_UnsubscribeBulk_RemovesItemAfterUnAdviseFailure() { @@ -484,6 +500,7 @@ public sealed class MxAccessCommandExecutorTests fakeComObject.OperationNames); } + /// Verifies that ShutdownGracefullyAsync cleans up handles in advice, item, server order. [Fact] public async Task ShutdownGracefullyAsync_CleansHandlesInAdviceItemServerOrder() { @@ -508,6 +525,7 @@ public sealed class MxAccessCommandExecutorTests || name.StartsWith("Remove", StringComparison.Ordinal))); } + /// Verifies that ShutdownGracefullyAsync records cleanup failures and continues. [Fact] public async Task ShutdownGracefullyAsync_RecordsCleanupFailuresAndContinues() { @@ -535,6 +553,7 @@ public sealed class MxAccessCommandExecutorTests Assert.Contains("Unregister:59", fakeComObject.OperationNames); } + /// Verifies that Register without payload returns an invalid request error. [Fact] public async Task DispatchAsync_RegisterWithoutPayload_ReturnsInvalidRequest() { @@ -555,6 +574,7 @@ public sealed class MxAccessCommandExecutorTests Assert.Null(factory.FakeComObject.RegisteredClientName); } + /// Verifies that AddItem without payload returns an invalid request error. [Fact] public async Task DispatchAsync_AddItemWithoutPayload_ReturnsInvalidRequest() { @@ -575,6 +595,7 @@ public sealed class MxAccessCommandExecutorTests Assert.Null(factory.FakeComObject.AddItemDefinition); } + /// Verifies that Advise without payload returns an invalid request error. [Fact] public async Task DispatchAsync_AdviseWithoutPayload_ReturnsInvalidRequest() { @@ -809,6 +830,17 @@ public sealed class MxAccessCommandExecutorTests private readonly Exception? adviseSupervisoryException; private readonly List operationNames = new(); + /// Initializes a fake MXAccess COM object with the given handles and optional exceptions. + /// Return value for Register method. + /// Return value for AddItem method. + /// Return value for AddItem2 method. + /// Exception to throw from Unregister, if any. + /// Exception to throw from AddItem, if any. + /// Exception to throw from AddItem2, if any. + /// Exception to throw from RemoveItem, if any. + /// Exception to throw from Advise, if any. + /// Exception to throw from UnAdvise, if any. + /// Exception to throw from AdviseSupervisory, if any. public FakeMxAccessComObject( int registerHandle, int addItemHandle = 0, @@ -833,54 +865,81 @@ public sealed class MxAccessCommandExecutorTests this.adviseSupervisoryException = adviseSupervisoryException; } + /// Gets the client name passed to Register, if called. public string? RegisteredClientName { get; private set; } + /// Gets the thread ID on which Register was called. public int? RegisterThreadId { get; private set; } + /// Gets the server handle passed to Unregister, if called. public int? UnregisteredServerHandle { get; private set; } + /// Gets the thread ID on which Unregister was called. public int? UnregisterThreadId { get; private set; } + /// Gets the server handle passed to AddItem, if called. public int? AddItemServerHandle { get; private set; } + /// Gets the item definition passed to AddItem, if called. public string? AddItemDefinition { get; private set; } + /// Gets the thread ID on which AddItem was called. public int? AddItemThreadId { get; private set; } + /// Gets the server handle passed to AddItem2, if called. public int? AddItem2ServerHandle { get; private set; } + /// Gets the item definition passed to AddItem2, if called. public string? AddItem2Definition { get; private set; } + /// Gets the item context passed to AddItem2, if called. public string? AddItem2Context { get; private set; } + /// Gets the thread ID on which AddItem2 was called. public int? AddItem2ThreadId { get; private set; } + /// Gets the server handle passed to RemoveItem, if called. public int? RemoveItemServerHandle { get; private set; } + /// Gets the item handle passed to RemoveItem, if called. public int? RemovedItemHandle { get; private set; } + /// Gets the thread ID on which RemoveItem was called. public int? RemoveItemThreadId { get; private set; } + /// Gets the server handle passed to Advise, if called. public int? AdviseServerHandle { get; private set; } + /// Gets the item handle passed to Advise, if called. public int? AdvisedItemHandle { get; private set; } + /// Gets the thread ID on which Advise was called. public int? AdviseThreadId { get; private set; } + /// Gets the server handle passed to UnAdvise, if called. public int? UnAdviseServerHandle { get; private set; } + /// Gets the item handle passed to UnAdvise, if called. public int? UnAdvisedItemHandle { get; private set; } + /// Gets the thread ID on which UnAdvise was called. public int? UnAdviseThreadId { get; private set; } + /// Gets the server handle passed to AdviseSupervisory, if called. public int? AdviseSupervisoryServerHandle { get; private set; } + /// Gets the item handle passed to AdviseSupervisory, if called. public int? AdviseSupervisoryItemHandle { get; private set; } + /// Gets the thread ID on which AdviseSupervisory was called. public int? AdviseSupervisoryThreadId { get; private set; } + /// Gets the list of operations performed on this fake object. public IReadOnlyList OperationNames => operationNames.ToArray(); + /// Registers a client and returns a server handle. + /// Name of the client to register. + /// The server handle for the registered client. public int Register(string clientName) { operationNames.Add($"Register:{clientName}"); @@ -890,6 +949,8 @@ public sealed class MxAccessCommandExecutorTests return registerHandle; } + /// Unregisters a server and tracks the operation. + /// Server handle to unregister. public void Unregister(int serverHandle) { operationNames.Add($"Unregister:{serverHandle}"); @@ -902,6 +963,10 @@ public sealed class MxAccessCommandExecutorTests } } + /// Adds an item to the server and returns its item handle. + /// Server handle to add the item to. + /// Item definition string. + /// The item handle for the added item. public int AddItem( int serverHandle, string itemDefinition) @@ -919,6 +984,11 @@ public sealed class MxAccessCommandExecutorTests return addItemHandle; } + /// Adds an item to the server with context and returns its item handle. + /// Server handle to add the item to. + /// Item definition string. + /// Item context string. + /// The item handle for the added item. public int AddItem2( int serverHandle, string itemDefinition, @@ -938,6 +1008,9 @@ public sealed class MxAccessCommandExecutorTests return addItem2Handle; } + /// Removes an item from the server and tracks the operation. + /// Server handle from which to remove the item. + /// Item handle to remove. public void RemoveItem( int serverHandle, int itemHandle) @@ -953,6 +1026,9 @@ public sealed class MxAccessCommandExecutorTests } } + /// Advises on item changes and tracks the operation. + /// Server handle for the advisory subscription. + /// Item handle to advise on. public void Advise( int serverHandle, int itemHandle) @@ -968,6 +1044,9 @@ public sealed class MxAccessCommandExecutorTests } } + /// Removes an item advice subscription and tracks the operation. + /// Server handle from which to remove the subscription. + /// Item handle to remove advice from. public void UnAdvise( int serverHandle, int itemHandle) @@ -983,6 +1062,9 @@ public sealed class MxAccessCommandExecutorTests } } + /// Advises supervisory on item changes and tracks the operation. + /// Server handle for the supervisory subscription. + /// Item handle to advise supervisory on. public void AdviseSupervisory( int serverHandle, int itemHandle) @@ -999,40 +1081,54 @@ public sealed class MxAccessCommandExecutorTests } } + /// Factory for creating fake MXAccess COM objects in tests. private sealed class FakeMxAccessComObjectFactory : IMxAccessComObjectFactory { + /// Initializes a new instance of the FakeMxAccessComObjectFactory class. + /// The fake COM object to return from Create. public FakeMxAccessComObjectFactory(FakeMxAccessComObject fakeComObject) { FakeComObject = fakeComObject; } + /// Gets the fake COM object. public FakeMxAccessComObject FakeComObject { get; } + /// Creates and returns the fake MXAccess COM object. + /// The fake COM object. public object Create() { return FakeComObject; } } + /// No-operation event sink for testing. private sealed class NoopEventSink : IMxAccessEventSink { + /// Attaches to a MXAccess COM object (no-op in test). + /// The MXAccess COM object to attach to. + /// Identifier of the session. public void Attach( object mxAccessComObject, string sessionId) { } + /// Detaches from the MXAccess COM object (no-op in test). public void Detach() { } } + /// No-operation STA apartment initializer for testing. private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer { + /// Initializes the STA apartment (no-op in test). public void Initialize() { } + /// Uninitializes the STA apartment (no-op in test). public void Uninitialize() { } diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs b/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs index 3d1dbb5..af5b002 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs @@ -8,6 +8,7 @@ public sealed class MxAccessEventMapperTests { private readonly MxAccessEventMapper mapper = new(); + /// Verifies that creating an OnDataChange event converts value, timestamp, quality, and statuses. [Fact] public void CreateOnDataChange_ConvertsValueTimestampQualityAndStatuses() { @@ -47,6 +48,7 @@ public sealed class MxAccessEventMapperTests Assert.Equal(MxStatusSource.RespondingAutomationObject, status.DetectedBy); } + /// Verifies that OnWriteComplete and OperationComplete events preserve distinct families. [Fact] public void CreateOnWriteCompleteAndOperationComplete_PreservesDistinctFamilies() { @@ -67,6 +69,7 @@ public sealed class MxAccessEventMapperTests Assert.Equal(MxEvent.BodyOneofCase.OperationComplete, operationComplete.BodyCase); } + /// Verifies that OnBufferedDataChange events preserve raw data type and array metadata. [Fact] public void CreateOnBufferedDataChange_PreservesRawDataTypeAndArrayMetadata() { @@ -92,6 +95,9 @@ public sealed class MxAccessEventMapperTests Assert.Equal(2, mxEvent.OnBufferedDataChange.TimestampValues.TimestampValues.Values.Count); } + /// Verifies that MapMxDataType maps raw MXAccess data types to protobuf enum values. + /// Raw MXAccess data type value to map. + /// Expected MxDataType enum value. [Theory] [InlineData(-1, MxDataType.Unknown)] [InlineData(0, MxDataType.NoData)] diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs b/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs index cf884ec..ee2bfb5 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/MxAccessEventQueueTests.cs @@ -7,6 +7,7 @@ namespace MxGateway.Worker.Tests.MxAccess; public sealed class MxAccessEventQueueTests { + /// Verifies that Enqueue assigns monotonic worker sequences and preserves event order. [Fact] public void Enqueue_AssignsMonotonicWorkerSequencesAndPreservesOrder() { @@ -28,6 +29,7 @@ public sealed class MxAccessEventQueueTests Assert.False(queue.TryDequeue(out _)); } + /// Verifies that Drain removes at most the requested number of events. [Fact] public void Drain_RemovesAtMostRequestedEvents() { @@ -44,6 +46,7 @@ public sealed class MxAccessEventQueueTests Assert.Equal(1, queue.Count); } + /// Verifies that Enqueue records an overflow fault and rejects new events when capacity is exceeded. [Fact] public void Enqueue_WhenCapacityIsExceeded_RecordsOverflowFaultAndRejectsNewEvents() { @@ -61,6 +64,7 @@ public sealed class MxAccessEventQueueTests () => queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 12))); } + /// Verifies that RecordFault keeps the first recorded fault. [Fact] public void RecordFault_KeepsFirstFault() { diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessInteropInfoTests.cs b/src/MxGateway.Worker.Tests/MxAccess/MxAccessInteropInfoTests.cs index 3821f44..746a131 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessInteropInfoTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/MxAccessInteropInfoTests.cs @@ -4,6 +4,7 @@ namespace MxGateway.Worker.Tests.MxAccess; public sealed class MxAccessInteropInfoTests { + /// Verifies that interop info identifies the correct MXAccess COM target. [Fact] public void InteropInfo_IdentifiesInstalledMxAccessComTarget() { @@ -13,6 +14,7 @@ public sealed class MxAccessInteropInfoTests Assert.Equal("ArchestrA.MxAccess.LMXProxyServerClass", MxAccessInteropInfo.ComClassName); } + /// Verifies that interop assembly name comes from referenced MXAccess assembly. [Fact] public void InteropAssemblyName_ComesFromReferencedMxAccessAssembly() { diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessLiveComCreationTests.cs b/src/MxGateway.Worker.Tests/MxAccess/MxAccessLiveComCreationTests.cs index 2593e19..0a6d56b 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessLiveComCreationTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/MxAccessLiveComCreationTests.cs @@ -13,6 +13,7 @@ public sealed class MxAccessLiveComCreationTests private const string DefaultLiveAddItem2Definition = "TestInt"; private const string DefaultLiveAddItem2Context = "TestChildObject"; + /// Verifies that StartAsync creates the installed MXAccess COM object on the STA thread when opted in. [Fact] public async Task StartAsync_WhenOptedIn_CreatesInstalledMxAccessComObjectOnSta() { @@ -29,6 +30,7 @@ public sealed class MxAccessLiveComCreationTests await session.StartAsync(workerProcessId: 1234); } + /// Verifies that Register and Unregister round-trip server handles with installed MXAccess. [Fact] public async Task RegisterAndUnregister_WhenOptedIn_RoundTripsInstalledMxAccessServerHandle() { @@ -70,6 +72,7 @@ public sealed class MxAccessLiveComCreationTests Assert.Equal(ProtocolStatusCode.Ok, unregisterReply.ProtocolStatus.Code); } + /// Verifies that AddItem and RemoveItem round-trip item handles with installed MXAccess. [Fact] public async Task AddItemAndRemoveItem_WhenOptedIn_RoundTripsInstalledMxAccessItemHandle() { @@ -142,6 +145,7 @@ public sealed class MxAccessLiveComCreationTests } } + /// Verifies that AddItem2 and RemoveItem preserve item context with installed MXAccess. [Fact] public async Task AddItem2AndRemoveItem_WhenOptedIn_PreservesContextForInstalledMxAccess() { @@ -215,6 +219,7 @@ public sealed class MxAccessLiveComCreationTests } } + /// Verifies that Advise and UnAdvise round-trip subscriptions with installed MXAccess. [Fact] public async Task AdviseAndUnAdvise_WhenOptedIn_RoundTripsInstalledMxAccessSubscription() { diff --git a/src/MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs b/src/MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs index 797e611..1dedfb8 100644 --- a/src/MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs +++ b/src/MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs @@ -8,8 +8,14 @@ using MxGateway.Worker.Sta; namespace MxGateway.Worker.Tests.MxAccess; +/// +/// Tests for . +/// public sealed class MxAccessStaSessionTests { + /// + /// Verifies that StartAsync creates the MXAccess COM object and attaches the event sink on the STA thread. + /// [Fact] public async Task StartAsync_CreatesComObjectAndAttachesEventSinkOnStaThread() { @@ -31,6 +37,9 @@ public sealed class MxAccessStaSessionTests Assert.Equal("session-1", eventSink.SessionId); } + /// + /// Verifies that StartAsync maps creation exceptions with HResult when the factory fails. + /// [Fact] public async Task StartAsync_WhenFactoryFails_MapsCreationExceptionWithHResult() { @@ -49,6 +58,9 @@ public sealed class MxAccessStaSessionTests Assert.Null(eventSink.AttachedObject); } + /// + /// Verifies that Dispose detaches the event sink on the STA thread. + /// [Fact] public async Task Dispose_DetachesEventSinkOnStaThread() { @@ -71,21 +83,40 @@ public sealed class MxAccessStaSessionTests TimeSpan.FromMilliseconds(25)); } + /// + /// Fake MXAccess COM object factory for testing. + /// private sealed class FakeMxAccessComObjectFactory : IMxAccessComObjectFactory { private readonly Exception? exception; + /// + /// Initializes a fake factory that optionally throws an exception. + /// + /// Exception to throw when Create is called; null to succeed. public FakeMxAccessComObjectFactory(Exception? exception = null) { this.exception = exception; } + /// + /// Gets the COM object created by this factory. + /// public object CreatedObject { get; } = new(); + /// + /// Gets the managed thread ID when Create was called. + /// public int? CreateThreadId { get; private set; } + /// + /// Gets the apartment state when Create was called. + /// public ApartmentState? CreateApartmentState { get; private set; } + /// + /// Creates the COM object or throws the configured exception. + /// public object Create() { CreateThreadId = Thread.CurrentThread.ManagedThreadId; @@ -100,16 +131,36 @@ public sealed class MxAccessStaSessionTests } } + /// + /// Fake MXAccess event sink for testing. + /// private sealed class FakeMxAccessEventSink : IMxAccessEventSink { + /// + /// Gets the attached MXAccess COM object. + /// public object? AttachedObject { get; private set; } + /// + /// Gets the managed thread ID when Attach was called. + /// public int? AttachThreadId { get; private set; } + /// + /// Gets the managed thread ID when Detach was called. + /// public int? DetachThreadId { get; private set; } + /// + /// Gets the session identifier. + /// public string? SessionId { get; private set; } + /// + /// Attaches the MXAccess COM object and records thread context. + /// + /// MXAccess COM object to attach. + /// Identifier of the session. public void Attach( object mxAccessComObject, string sessionId) @@ -119,6 +170,9 @@ public sealed class MxAccessStaSessionTests SessionId = sessionId; } + /// + /// Detaches the MXAccess COM object and records thread context. + /// public void Detach() { DetachThreadId = Thread.CurrentThread.ManagedThreadId; @@ -126,12 +180,21 @@ public sealed class MxAccessStaSessionTests } } + /// + /// Noop STA COM apartment initializer for testing. + /// private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer { + /// + /// Initializes the COM apartment (no-op). + /// public void Initialize() { } + /// + /// Uninitializes the COM apartment (no-op). + /// public void Uninitialize() { } diff --git a/src/MxGateway.Worker.Tests/ProjectStructure/WorkerProjectReferenceTests.cs b/src/MxGateway.Worker.Tests/ProjectStructure/WorkerProjectReferenceTests.cs index 137a70f..fb0dc1a 100644 --- a/src/MxGateway.Worker.Tests/ProjectStructure/WorkerProjectReferenceTests.cs +++ b/src/MxGateway.Worker.Tests/ProjectStructure/WorkerProjectReferenceTests.cs @@ -8,6 +8,7 @@ namespace MxGateway.Worker.Tests.ProjectStructure; public sealed class WorkerProjectReferenceTests { + /// Verifies that the worker project targets .NET Framework 4.8 with x86 platform. [Fact] public void WorkerProject_TargetsNet48AndX86() { @@ -18,6 +19,7 @@ public sealed class WorkerProjectReferenceTests Assert.Equal("true", ElementValue(project, "Prefer32Bit")); } + /// Verifies that the worker test project targets .NET Framework 4.8 with x86 platform. [Fact] public void WorkerTestProject_TargetsNet48AndX86() { @@ -27,6 +29,7 @@ public sealed class WorkerProjectReferenceTests Assert.Equal("x86", ElementValue(project, "PlatformTarget")); } + /// Verifies that MXAccess interop reference exists only in the worker project. [Fact] public void MxAccessInteropReference_ExistsOnlyInWorkerProject() { diff --git a/src/MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs b/src/MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs index 35032ce..c81edda 100644 --- a/src/MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs +++ b/src/MxGateway.Worker.Tests/Sta/StaCommandDispatcherTests.cs @@ -9,8 +9,14 @@ using MxGateway.Worker.Sta; namespace MxGateway.Worker.Tests.Sta; +/// +/// Tests for StaCommandDispatcher command queueing and execution. +/// public sealed class StaCommandDispatcherTests { + /// + /// Verifies commands execute on the STA thread in queue order. + /// [Fact] public async Task DispatchAsync_ExecutesCommandsOnStaInQueueOrder() { @@ -31,6 +37,9 @@ public sealed class StaCommandDispatcherTests Assert.Equal(ProtocolStatusCode.Ok, replies[0].ProtocolStatus.Code); } + /// + /// Verifies executor exceptions are captured as HResult in the reply without exposing message details. + /// [Fact] public async Task DispatchAsync_WhenExecutorThrows_ReturnsFailureReplyWithHResult() { @@ -51,6 +60,9 @@ public sealed class StaCommandDispatcherTests Assert.DoesNotContain("provider detail", reply.DiagnosticMessage); } + /// + /// Verifies cancellation before execution prevents the command from running. + /// [Fact] public async Task DispatchAsync_WhenCanceledBeforeExecution_ReturnsCanceledReplyWithoutExecuting() { @@ -74,6 +86,9 @@ public sealed class StaCommandDispatcherTests Assert.DoesNotContain("canceled", executor.CorrelationIds); } + /// + /// Verifies cancellation after execution starts still returns the reply once execution completes. + /// [Fact] public async Task DispatchAsync_WhenCanceledAfterExecutionStarts_StillReturnsLateReply() { @@ -96,6 +111,9 @@ public sealed class StaCommandDispatcherTests Assert.Contains("late-reply", executor.CorrelationIds); } + /// + /// Verifies shutdown rejects new dispatch attempts. + /// [Fact] public async Task DispatchAsync_WhenShutdownRequested_RejectsNewCommands() { @@ -110,6 +128,9 @@ public sealed class StaCommandDispatcherTests Assert.Equal("correlation-1", reply.CorrelationId); } + /// + /// Verifies shutdown allows the current command to complete but rejects queued commands. + /// [Fact] public async Task RequestShutdown_RejectsQueuedCommandButLetsCurrentCommandFinish() { @@ -131,6 +152,9 @@ public sealed class StaCommandDispatcherTests Assert.Equal(new[] { "current" }, executor.CorrelationIds); } + /// + /// Verifies heartbeat reports current command correlation ID and pending command count. + /// [Fact] public async Task PopulateHeartbeat_ReportsCurrentCorrelationAndPendingCount() { @@ -180,12 +204,18 @@ public sealed class StaCommandDispatcherTests TimeSpan.FromMilliseconds(25)); } + /// + /// Test executor that records executed command correlations and thread IDs. + /// private sealed class RecordingCommandExecutor : IStaCommandExecutor { private readonly object gate = new(); private readonly List correlationIds = new(); private readonly List threadIds = new(); + /// + /// List of correlation IDs from executed commands. + /// public IReadOnlyList CorrelationIds { get @@ -197,6 +227,9 @@ public sealed class StaCommandDispatcherTests } } + /// + /// List of thread IDs on which commands executed. + /// public IReadOnlyList ThreadIds { get @@ -208,6 +241,7 @@ public sealed class StaCommandDispatcherTests } } + /// public MxCommandReply Execute(StaCommand command) { lock (gate) @@ -227,14 +261,23 @@ public sealed class StaCommandDispatcherTests } } + /// + /// Test executor that blocks execution until explicitly released. + /// private sealed class BlockingCommandExecutor : IStaCommandExecutor { private readonly ManualResetEventSlim release = new(false); private readonly object gate = new(); private readonly List correlationIds = new(); + /// + /// Signals when execution of the current command has started. + /// public ManualResetEventSlim Started { get; } = new(false); + /// + /// List of correlation IDs from executed commands. + /// public IReadOnlyList CorrelationIds { get @@ -246,6 +289,7 @@ public sealed class StaCommandDispatcherTests } } + /// public MxCommandReply Execute(StaCommand command) { lock (gate) @@ -266,33 +310,49 @@ public sealed class StaCommandDispatcherTests }; } + /// + /// Unblocks the waiting command execution. + /// public void Release() { release.Set(); } } + /// + /// Test executor that always throws a configured exception. + /// private sealed class ThrowingCommandExecutor : IStaCommandExecutor { private readonly Exception exception; + /// + /// Initializes with the exception to throw. + /// + /// Exception to throw on execution. public ThrowingCommandExecutor(Exception exception) { this.exception = exception; } + /// public MxCommandReply Execute(StaCommand command) { throw exception; } } + /// + /// No-op COM apartment initializer for testing. + /// private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer { + /// public void Initialize() { } + /// public void Uninitialize() { } diff --git a/src/MxGateway.Worker.Tests/Sta/StaRuntimeTests.cs b/src/MxGateway.Worker.Tests/Sta/StaRuntimeTests.cs index 43a1014..f068b80 100644 --- a/src/MxGateway.Worker.Tests/Sta/StaRuntimeTests.cs +++ b/src/MxGateway.Worker.Tests/Sta/StaRuntimeTests.cs @@ -8,6 +8,7 @@ namespace MxGateway.Worker.Tests.Sta; public sealed class StaRuntimeTests { + /// Verifies that InvokeAsync executes commands on the STA thread. [Fact] public async Task InvokeAsync_ExecutesCommandOnStaThread() { @@ -26,6 +27,7 @@ public sealed class StaRuntimeTests Assert.Equal(ApartmentState.STA, observation.ApartmentState); } + /// Verifies that InvokeAsync wakes the idle pump when a command is queued. [Fact] public async Task InvokeAsync_WakesIdlePumpForQueuedCommand() { @@ -46,6 +48,7 @@ public sealed class StaRuntimeTests $"Command took {stopwatch.Elapsed} to execute, so the command wake event did not wake the STA promptly."); } + /// Verifies that Shutdown stops the thread and uninitializes the COM apartment. [Fact] public void Shutdown_StopsThreadAndUninitializesComApartment() { @@ -62,6 +65,7 @@ public sealed class StaRuntimeTests Assert.Equal(initializer.InitializeThreadId, initializer.UninitializeThreadId); } + /// Verifies that LastActivityUtc updates while the pump is idle. [Fact] public void LastActivityUtc_UpdatesWhilePumpIsIdle() { @@ -77,6 +81,7 @@ public sealed class StaRuntimeTests Assert.True(updated); } + /// Verifies that InvokeAsync faults the returned task when a command raises an exception without stopping the runtime. [Fact] public async Task InvokeAsync_CommandException_FaultsReturnedTaskWithoutStoppingRuntime() { @@ -92,6 +97,7 @@ public sealed class StaRuntimeTests Assert.Equal(runtime.StaThreadId, threadId); } + /// Verifies that InvokeAsync returns a faulted task when called after Shutdown. [Fact] public async Task InvokeAsync_AfterShutdown_ReturnsFaultedTask() { @@ -114,35 +120,47 @@ public sealed class StaRuntimeTests TimeSpan.FromMilliseconds(25)); } + /// Records the thread ID and apartment state of an STA command execution. private sealed class StaCommandObservation { + /// Initializes a new instance of the StaCommandObservation class. + /// Managed thread ID where the command executed. + /// COM apartment state of the thread. public StaCommandObservation(int threadId, ApartmentState apartmentState) { ThreadId = threadId; ApartmentState = apartmentState; } + /// The thread ID where the command executed. public int ThreadId { get; } + /// The apartment state of the thread. public ApartmentState ApartmentState { get; } } private sealed class RecordingComApartmentInitializer : IStaComApartmentInitializer { + /// The number of times Initialize was called. public int InitializeCount { get; private set; } + /// The number of times Uninitialize was called. public int UninitializeCount { get; private set; } + /// The thread ID where Initialize was called. public int? InitializeThreadId { get; private set; } + /// The thread ID where Uninitialize was called. public int? UninitializeThreadId { get; private set; } + /// Initializes the COM apartment and records the calling thread. public void Initialize() { InitializeCount++; InitializeThreadId = Thread.CurrentThread.ManagedThreadId; } + /// Uninitializes the COM apartment and records the calling thread. public void Uninitialize() { UninitializeCount++; diff --git a/src/MxGateway.Worker/Bootstrap/EnvironmentVariableWorkerEnvironment.cs b/src/MxGateway.Worker/Bootstrap/EnvironmentVariableWorkerEnvironment.cs index 7f6aec6..3205976 100644 --- a/src/MxGateway.Worker/Bootstrap/EnvironmentVariableWorkerEnvironment.cs +++ b/src/MxGateway.Worker/Bootstrap/EnvironmentVariableWorkerEnvironment.cs @@ -2,8 +2,12 @@ using System; namespace MxGateway.Worker.Bootstrap; +/// +/// Worker environment that reads from system environment variables. +/// public sealed class EnvironmentVariableWorkerEnvironment : IWorkerEnvironment { + /// public string? GetEnvironmentVariable(string name) { return Environment.GetEnvironmentVariable(name); diff --git a/src/MxGateway.Worker/Bootstrap/IWorkerEnvironment.cs b/src/MxGateway.Worker/Bootstrap/IWorkerEnvironment.cs index 3030b62..03b342a 100644 --- a/src/MxGateway.Worker/Bootstrap/IWorkerEnvironment.cs +++ b/src/MxGateway.Worker/Bootstrap/IWorkerEnvironment.cs @@ -1,6 +1,14 @@ namespace MxGateway.Worker.Bootstrap; +/// +/// Abstracts access to environment variables for the worker. +/// public interface IWorkerEnvironment { + /// + /// Gets an environment variable by name. + /// + /// Name of the environment variable. + /// The value of the environment variable, or null if not found. string? GetEnvironmentVariable(string name); } diff --git a/src/MxGateway.Worker/Bootstrap/IWorkerLogger.cs b/src/MxGateway.Worker/Bootstrap/IWorkerLogger.cs index e9583a4..36a8cac 100644 --- a/src/MxGateway.Worker/Bootstrap/IWorkerLogger.cs +++ b/src/MxGateway.Worker/Bootstrap/IWorkerLogger.cs @@ -4,7 +4,17 @@ namespace MxGateway.Worker.Bootstrap; public interface IWorkerLogger { + /// + /// Logs an informational event with fields. + /// + /// Event name. + /// Event fields. void Information(string eventName, IReadOnlyDictionary fields); + /// + /// Logs an error event with fields. + /// + /// Event name. + /// Event fields. void Error(string eventName, IReadOnlyDictionary fields); } diff --git a/src/MxGateway.Worker/Bootstrap/WorkerBootstrapResult.cs b/src/MxGateway.Worker/Bootstrap/WorkerBootstrapResult.cs index 0e751bc..003383d 100644 --- a/src/MxGateway.Worker/Bootstrap/WorkerBootstrapResult.cs +++ b/src/MxGateway.Worker/Bootstrap/WorkerBootstrapResult.cs @@ -15,19 +15,42 @@ public sealed class WorkerBootstrapResult Errors = errors; } + /// + /// Gets the worker process exit code. + /// public WorkerExitCode ExitCode { get; } + /// + /// Gets the bootstrap options if bootstrap succeeded. + /// public WorkerOptions? Options { get; } + /// + /// Gets the list of bootstrap errors if any. + /// public IReadOnlyList Errors { get; } + /// + /// Gets a value indicating whether bootstrap succeeded. + /// public bool Succeeded => ExitCode == WorkerExitCode.Success; + /// + /// Creates a successful bootstrap result with the given options. + /// + /// Bootstrap options. + /// Successful bootstrap result. public static WorkerBootstrapResult Success(WorkerOptions options) { return new WorkerBootstrapResult(WorkerExitCode.Success, options, []); } + /// + /// Creates a failed bootstrap result with the given exit code and errors. + /// + /// Worker exit code. + /// Bootstrap errors. + /// Failed bootstrap result. public static WorkerBootstrapResult Failure(WorkerExitCode exitCode, IEnumerable errors) { return new WorkerBootstrapResult(exitCode, null, errors.ToArray()); diff --git a/src/MxGateway.Worker/Bootstrap/WorkerConsoleLogger.cs b/src/MxGateway.Worker/Bootstrap/WorkerConsoleLogger.cs index fefe9e8..e1b3212 100644 --- a/src/MxGateway.Worker/Bootstrap/WorkerConsoleLogger.cs +++ b/src/MxGateway.Worker/Bootstrap/WorkerConsoleLogger.cs @@ -9,16 +9,24 @@ public sealed class WorkerConsoleLogger : IWorkerLogger { private readonly TextWriter _writer; + /// Initializes a new worker console logger. + /// Text writer destination for log output. public WorkerConsoleLogger(TextWriter writer) { _writer = writer ?? throw new ArgumentNullException(nameof(writer)); } + /// Writes an informational log entry. + /// Name of the event being logged. + /// Event fields and values to log. public void Information(string eventName, IReadOnlyDictionary fields) { Write("Information", eventName, fields); } + /// Writes an error log entry. + /// Name of the event being logged. + /// Event fields and values to log. public void Error(string eventName, IReadOnlyDictionary fields) { Write("Error", eventName, fields); diff --git a/src/MxGateway.Worker/Bootstrap/WorkerLogRedactor.cs b/src/MxGateway.Worker/Bootstrap/WorkerLogRedactor.cs index 860576c..4304eb6 100644 --- a/src/MxGateway.Worker/Bootstrap/WorkerLogRedactor.cs +++ b/src/MxGateway.Worker/Bootstrap/WorkerLogRedactor.cs @@ -3,8 +3,14 @@ using System.Collections.Generic; namespace MxGateway.Worker.Bootstrap; +/// +/// Redacts sensitive fields from worker log messages. +/// public static class WorkerLogRedactor { + /// + /// Replacement text for redacted values. + /// public const string RedactedValue = "[redacted]"; private static readonly string[] SensitiveFieldNameParts = @@ -18,6 +24,10 @@ public static class WorkerLogRedactor "api_key", ]; + /// + /// Redacts sensitive field values from a log field dictionary. + /// + /// Dictionary of field names and values. public static Dictionary RedactFields(IReadOnlyDictionary fields) { Dictionary redactedFields = []; @@ -30,6 +40,11 @@ public static class WorkerLogRedactor return redactedFields; } + /// + /// Redacts a single value if its field name contains sensitive keywords. + /// + /// Name of the field to check. + /// Value to redact if sensitive. public static object? RedactValue(string fieldName, object? value) { if (value is null) diff --git a/src/MxGateway.Worker/Bootstrap/WorkerOptions.cs b/src/MxGateway.Worker/Bootstrap/WorkerOptions.cs index 4317b5d..4b8afbe 100644 --- a/src/MxGateway.Worker/Bootstrap/WorkerOptions.cs +++ b/src/MxGateway.Worker/Bootstrap/WorkerOptions.cs @@ -1,9 +1,16 @@ namespace MxGateway.Worker.Bootstrap; +/// Worker bootstrap options passed via environment variables and named pipes. public sealed class WorkerOptions { + /// Environment variable name for the worker nonce. public const string NonceEnvironmentVariableName = "MXGATEWAY_WORKER_NONCE"; + /// Initializes worker options from a bootstrap handshake. + /// Identifier of the session. + /// Named pipe name for gateway communication. + /// Protocol version agreed with the gateway. + /// Authentication nonce for the handshake. public WorkerOptions( string sessionId, string pipeName, @@ -16,11 +23,15 @@ public sealed class WorkerOptions Nonce = nonce; } + /// Unique identifier for the gateway session this worker serves. public string SessionId { get; } + /// Named pipe name for communicating with the gateway. public string PipeName { get; } + /// Worker protocol version negotiated with the gateway. public uint ProtocolVersion { get; } + /// Nonce used to authenticate the handshake with the gateway. public string Nonce { get; } } diff --git a/src/MxGateway.Worker/Bootstrap/WorkerOptionsParser.cs b/src/MxGateway.Worker/Bootstrap/WorkerOptionsParser.cs index fdd236b..c5c9a6b 100644 --- a/src/MxGateway.Worker/Bootstrap/WorkerOptionsParser.cs +++ b/src/MxGateway.Worker/Bootstrap/WorkerOptionsParser.cs @@ -4,6 +4,9 @@ using MxGateway.Contracts; namespace MxGateway.Worker.Bootstrap; +/// +/// Parses worker command-line arguments and environment variables. +/// public sealed class WorkerOptionsParser { private const string SessionIdOptionName = "--session-id"; @@ -12,11 +15,20 @@ public sealed class WorkerOptionsParser private readonly IWorkerEnvironment _environment; + /// + /// Initializes the parser with a worker environment. + /// + /// Worker environment for reading configuration. public WorkerOptionsParser(IWorkerEnvironment environment) { _environment = environment ?? throw new ArgumentNullException(nameof(environment)); } + /// + /// Parses command-line arguments and returns bootstrap configuration or errors. + /// + /// Command-line arguments to parse. + /// Bootstrap result containing configuration or error messages. public WorkerBootstrapResult Parse(string[] args) { if (args is null) diff --git a/src/MxGateway.Worker/Conversion/HResultConversion.cs b/src/MxGateway.Worker/Conversion/HResultConversion.cs index fced01e..fab120d 100644 --- a/src/MxGateway.Worker/Conversion/HResultConversion.cs +++ b/src/MxGateway.Worker/Conversion/HResultConversion.cs @@ -2,8 +2,13 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Worker.Conversion; +/// Result of converting an HResult to a protocol status and diagnostic message. public sealed class HResultConversion { + /// Initializes the conversion result. + /// The original HResult value. + /// The converted protocol status. + /// Diagnostic message describing the HResult. public HResultConversion( int hresult, ProtocolStatus protocolStatus, @@ -14,9 +19,12 @@ public sealed class HResultConversion DiagnosticMessage = diagnosticMessage; } + /// The original HResult value. public int HResult { get; } + /// The converted protocol status. public ProtocolStatus ProtocolStatus { get; } + /// Diagnostic message describing the HResult. public string DiagnosticMessage { get; } } diff --git a/src/MxGateway.Worker/Conversion/HResultConverter.cs b/src/MxGateway.Worker/Conversion/HResultConverter.cs index cd8db53..b428008 100644 --- a/src/MxGateway.Worker/Conversion/HResultConverter.cs +++ b/src/MxGateway.Worker/Conversion/HResultConverter.cs @@ -6,6 +6,9 @@ namespace MxGateway.Worker.Conversion; public sealed class HResultConverter { + /// Converts an exception to an HResult conversion with protocol status and diagnostic message. + /// Exception to convert. + /// Conversion result with HResult, protocol status, and diagnostics. public HResultConversion Convert(Exception exception) { if (exception is null) @@ -23,6 +26,10 @@ public sealed class HResultConverter CreateSafeDiagnosticMessage(exception)); } + /// Creates a protocol status from an HResult code and optional exception. + /// HResult error code. + /// Exception providing additional context. + /// Protocol status with mapped code and message. public ProtocolStatus CreateProtocolStatus( int hresult, Exception? exception = null) diff --git a/src/MxGateway.Worker/Conversion/MxStatusConversionException.cs b/src/MxGateway.Worker/Conversion/MxStatusConversionException.cs index d146eca..9a63d80 100644 --- a/src/MxGateway.Worker/Conversion/MxStatusConversionException.cs +++ b/src/MxGateway.Worker/Conversion/MxStatusConversionException.cs @@ -4,6 +4,8 @@ namespace MxGateway.Worker.Conversion; public sealed class MxStatusConversionException : Exception { + /// Initializes a new instance of the class. + /// The exception message. public MxStatusConversionException(string message) : base(message) { diff --git a/src/MxGateway.Worker/Conversion/MxStatusDetailText.cs b/src/MxGateway.Worker/Conversion/MxStatusDetailText.cs index 086157e..8cf21e6 100644 --- a/src/MxGateway.Worker/Conversion/MxStatusDetailText.cs +++ b/src/MxGateway.Worker/Conversion/MxStatusDetailText.cs @@ -47,6 +47,9 @@ internal static class MxStatusDetailText [8017] = "Object must be offscan to modify attributes that have an MxSecurityConfigure security classification", }; + /// Looks up the text description for an MXAccess status detail code. + /// Status detail code. + /// Description text if found; otherwise empty string. public static string Lookup(int detail) { return KnownDetails.TryGetValue(detail, out string text) ? text : string.Empty; diff --git a/src/MxGateway.Worker/Conversion/MxStatusProxyConverter.cs b/src/MxGateway.Worker/Conversion/MxStatusProxyConverter.cs index fc6984f..ef5b64d 100644 --- a/src/MxGateway.Worker/Conversion/MxStatusProxyConverter.cs +++ b/src/MxGateway.Worker/Conversion/MxStatusProxyConverter.cs @@ -6,8 +6,11 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Worker.Conversion; +/// Converts MXAccess MXSTATUS_PROXY COM objects to protobuf MxStatusProxy messages. public sealed class MxStatusProxyConverter { + /// Converts a single status object to a protobuf message, reflecting all fields and diagnostics. + /// COM status object to convert. public MxStatusProxy Convert(object status) { if (status is null) @@ -33,6 +36,8 @@ public sealed class MxStatusProxyConverter }; } + /// Converts an array of status objects, handling nulls gracefully. + /// Array of COM status objects; null returns empty list. public IReadOnlyList ConvertMany(Array? statuses) { if (statuses is null) @@ -60,6 +65,8 @@ public sealed class MxStatusProxyConverter return converted; } + /// Preserves completion-only status bytes as a diagnostic hex string since they cannot be unpacked. + /// Status bytes to encode as hex string. public string PreserveCompletionOnlyStatusBytes(byte[] statusBytes) { if (statusBytes is null) diff --git a/src/MxGateway.Worker/Conversion/VariantConverter.cs b/src/MxGateway.Worker/Conversion/VariantConverter.cs index 9004c2f..4f55ee1 100644 --- a/src/MxGateway.Worker/Conversion/VariantConverter.cs +++ b/src/MxGateway.Worker/Conversion/VariantConverter.cs @@ -8,11 +8,22 @@ namespace MxGateway.Worker.Conversion; public sealed class VariantConverter { + /// + /// Converts an object value to an MxValue without a specified data type. + /// + /// Value to convert. + /// Converted MxValue. public MxValue Convert(object? value) { return Convert(value, MxDataType.Unspecified); } + /// + /// Converts an object value to an MxValue with an expected data type. + /// + /// Value to convert. + /// Expected MXAccess data type. + /// Converted MxValue. public MxValue Convert( object? value, MxDataType expectedDataType) @@ -35,6 +46,12 @@ public sealed class VariantConverter return ConvertScalar(value, expectedDataType); } + /// + /// Converts a .NET array to an MxArray. + /// + /// Array to convert. + /// Expected data type for array elements. + /// Converted MxArray. public MxArray ConvertArray( Array array, MxDataType expectedElementDataType = MxDataType.Unspecified) diff --git a/src/MxGateway.Worker/Ipc/IWorkerPipeClient.cs b/src/MxGateway.Worker/Ipc/IWorkerPipeClient.cs index 8812f38..d40c162 100644 --- a/src/MxGateway.Worker/Ipc/IWorkerPipeClient.cs +++ b/src/MxGateway.Worker/Ipc/IWorkerPipeClient.cs @@ -4,8 +4,12 @@ using MxGateway.Worker.Bootstrap; namespace MxGateway.Worker.Ipc; +/// Manages the worker's named pipe connection to the gateway. public interface IWorkerPipeClient { + /// Connects to the gateway and runs the worker until the session ends or is cancelled. + /// Configuration options. + /// Token to cancel the asynchronous operation. Task RunAsync( WorkerOptions options, CancellationToken cancellationToken = default); diff --git a/src/MxGateway.Worker/Ipc/WorkerContractInfo.cs b/src/MxGateway.Worker/Ipc/WorkerContractInfo.cs index 4a26d1d..7a7ec19 100644 --- a/src/MxGateway.Worker/Ipc/WorkerContractInfo.cs +++ b/src/MxGateway.Worker/Ipc/WorkerContractInfo.cs @@ -5,7 +5,9 @@ namespace MxGateway.Worker.Ipc; public static class WorkerContractInfo { + /// The worker protocol version supported by this contract. public static uint SupportedProtocolVersion => GatewayContractInfo.WorkerProtocolVersion; + /// The fully qualified name of the WorkerEnvelope message descriptor. public static string WorkerEnvelopeDescriptorName => WorkerEnvelope.Descriptor.FullName; } diff --git a/src/MxGateway.Worker/Ipc/WorkerEnvelopeValidator.cs b/src/MxGateway.Worker/Ipc/WorkerEnvelopeValidator.cs index ebe3823..d6f8342 100644 --- a/src/MxGateway.Worker/Ipc/WorkerEnvelopeValidator.cs +++ b/src/MxGateway.Worker/Ipc/WorkerEnvelopeValidator.cs @@ -3,8 +3,12 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Worker.Ipc; +/// Validates worker envelope frames against protocol options. internal static class WorkerEnvelopeValidator { + /// Validates a worker envelope for protocol compliance. + /// The envelope to validate. + /// The frame protocol configuration. public static void Validate( WorkerEnvelope envelope, WorkerFrameProtocolOptions options) diff --git a/src/MxGateway.Worker/Ipc/WorkerFrameProtocolException.cs b/src/MxGateway.Worker/Ipc/WorkerFrameProtocolException.cs index 25e3b1d..f426947 100644 --- a/src/MxGateway.Worker/Ipc/WorkerFrameProtocolException.cs +++ b/src/MxGateway.Worker/Ipc/WorkerFrameProtocolException.cs @@ -2,8 +2,16 @@ using System; namespace MxGateway.Worker.Ipc; +/// +/// Exception raised when the named-pipe frame protocol encounters an error. +/// public sealed class WorkerFrameProtocolException : Exception { + /// + /// Initializes with an error code and message. + /// + /// Protocol error classification. + /// Exception message. public WorkerFrameProtocolException( WorkerFrameProtocolErrorCode errorCode, string message) @@ -12,6 +20,12 @@ public sealed class WorkerFrameProtocolException : Exception ErrorCode = errorCode; } + /// + /// Initializes with an error code, message, and inner exception. + /// + /// Protocol error classification. + /// Exception message. + /// Underlying cause. public WorkerFrameProtocolException( WorkerFrameProtocolErrorCode errorCode, string message, @@ -21,5 +35,8 @@ public sealed class WorkerFrameProtocolException : Exception ErrorCode = errorCode; } + /// + /// The protocol error code classifying the failure. + /// public WorkerFrameProtocolErrorCode ErrorCode { get; } } diff --git a/src/MxGateway.Worker/Ipc/WorkerFrameProtocolOptions.cs b/src/MxGateway.Worker/Ipc/WorkerFrameProtocolOptions.cs index 123b979..22387a7 100644 --- a/src/MxGateway.Worker/Ipc/WorkerFrameProtocolOptions.cs +++ b/src/MxGateway.Worker/Ipc/WorkerFrameProtocolOptions.cs @@ -4,10 +4,14 @@ using MxGateway.Worker.Bootstrap; namespace MxGateway.Worker.Ipc; +/// Configuration options for the worker frame protocol. public sealed class WorkerFrameProtocolOptions { + /// Default maximum message size in bytes (16 MB). public const int DefaultMaxMessageBytes = 16 * 1024 * 1024; + /// Initializes a new instance of the WorkerFrameProtocolOptions class from WorkerOptions. + /// Worker initialization options. public WorkerFrameProtocolOptions(WorkerOptions options) : this( options?.SessionId ?? throw new ArgumentNullException(nameof(options)), @@ -17,6 +21,10 @@ public sealed class WorkerFrameProtocolOptions { } + /// Initializes a new instance of the WorkerFrameProtocolOptions class with default max message bytes. + /// Identifier of the session. + /// Protocol version. + /// Nonce for startup validation. public WorkerFrameProtocolOptions( string sessionId, uint protocolVersion, @@ -29,6 +37,11 @@ public sealed class WorkerFrameProtocolOptions { } + /// Initializes a new instance of the WorkerFrameProtocolOptions class with all parameters. + /// Identifier of the session. + /// Protocol version. + /// Nonce for startup validation. + /// Maximum message size in bytes. public WorkerFrameProtocolOptions( string sessionId, uint protocolVersion, @@ -76,11 +89,15 @@ public sealed class WorkerFrameProtocolOptions MaxMessageBytes = maxMessageBytes; } + /// Gets the session ID for the worker protocol. public string SessionId { get; } + /// Gets the protocol version. public uint ProtocolVersion { get; } + /// Gets the nonce for startup validation. public string Nonce { get; } + /// Gets the maximum message size in bytes. public int MaxMessageBytes { get; } } diff --git a/src/MxGateway.Worker/Ipc/WorkerFrameReader.cs b/src/MxGateway.Worker/Ipc/WorkerFrameReader.cs index 1e7cbd1..6324ad6 100644 --- a/src/MxGateway.Worker/Ipc/WorkerFrameReader.cs +++ b/src/MxGateway.Worker/Ipc/WorkerFrameReader.cs @@ -7,11 +7,15 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Worker.Ipc; +/// Reads length-prefixed WorkerEnvelope protobuf frames from a stream. public sealed class WorkerFrameReader { private readonly WorkerFrameProtocolOptions _options; private readonly Stream _stream; + /// Initializes the reader with a stream and protocol options. + /// Stream to read frames from. + /// Protocol options for frame validation. public WorkerFrameReader( Stream stream, WorkerFrameProtocolOptions options) @@ -20,6 +24,8 @@ public sealed class WorkerFrameReader _options = options ?? throw new ArgumentNullException(nameof(options)); } + /// Reads and validates a single length-prefixed frame from the stream. + /// Token to cancel the asynchronous operation. public async Task ReadAsync(CancellationToken cancellationToken = default) { byte[] lengthPrefix = new byte[sizeof(uint)]; diff --git a/src/MxGateway.Worker/Ipc/WorkerFrameWriter.cs b/src/MxGateway.Worker/Ipc/WorkerFrameWriter.cs index 934faef..3e68106 100644 --- a/src/MxGateway.Worker/Ipc/WorkerFrameWriter.cs +++ b/src/MxGateway.Worker/Ipc/WorkerFrameWriter.cs @@ -7,12 +7,16 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Worker.Ipc; +/// Writes worker frames to a stream with length-prefixed protobuf serialization. public sealed class WorkerFrameWriter { private readonly WorkerFrameProtocolOptions _options; private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly Stream _stream; + /// Initializes a new instance of the WorkerFrameWriter class. + /// Stream to write frames to. + /// Protocol options for frame encoding. public WorkerFrameWriter( Stream stream, WorkerFrameProtocolOptions options) @@ -21,6 +25,9 @@ public sealed class WorkerFrameWriter _options = options ?? throw new ArgumentNullException(nameof(options)); } + /// Writes a worker envelope frame to the stream with length prefix. + /// Worker envelope to write. + /// Token to cancel the asynchronous operation. public async Task WriteAsync( WorkerEnvelope envelope, CancellationToken cancellationToken = default) diff --git a/src/MxGateway.Worker/Ipc/WorkerPipeClient.cs b/src/MxGateway.Worker/Ipc/WorkerPipeClient.cs index dc765fa..f37cfaa 100644 --- a/src/MxGateway.Worker/Ipc/WorkerPipeClient.cs +++ b/src/MxGateway.Worker/Ipc/WorkerPipeClient.cs @@ -10,10 +10,18 @@ using Polly.Retry; namespace MxGateway.Worker.Ipc; +/// +/// Connects to the gateway via a named pipe and runs the worker frame protocol session. +/// public sealed class WorkerPipeClient : IWorkerPipeClient { + /// Default overall connection timeout in milliseconds. public const int DefaultConnectTimeoutMilliseconds = 30000; + + /// Default per-attempt connection timeout in milliseconds. public const int DefaultConnectAttemptTimeoutMilliseconds = 2000; + + /// Environment variable for overriding the per-attempt connection timeout. public const string ConnectAttemptTimeoutEnvironmentVariableName = "MXGATEWAY_WORKER_PIPE_CONNECT_ATTEMPT_TIMEOUT_MS"; @@ -22,21 +30,37 @@ public sealed class WorkerPipeClient : IWorkerPipeClient private readonly Func _sessionFactory; private readonly IWorkerLogger? _logger; + /// + /// Initializes a worker pipe client with default timeouts. + /// public WorkerPipeClient() : this(null, DefaultConnectTimeoutMilliseconds) { } + /// + /// Initializes a worker pipe client with a logger and default timeouts. + /// + /// Optional logger for diagnostic output. public WorkerPipeClient(IWorkerLogger? logger) : this(logger, DefaultConnectTimeoutMilliseconds) { } + /// + /// Initializes a worker pipe client with a custom overall connect timeout. + /// + /// Overall connection timeout in milliseconds. public WorkerPipeClient(int connectTimeoutMilliseconds) : this(null, connectTimeoutMilliseconds) { } + /// + /// Initializes a worker pipe client with custom timeouts and a session factory. + /// + /// Overall connection timeout in milliseconds. + /// Factory creating the worker pipe session. public WorkerPipeClient( int connectTimeoutMilliseconds, Func sessionFactory) @@ -48,6 +72,11 @@ public sealed class WorkerPipeClient : IWorkerPipeClient { } + /// + /// Initializes a worker pipe client with a logger and custom overall timeout. + /// + /// Optional logger for diagnostic output. + /// Overall connection timeout in milliseconds. public WorkerPipeClient( IWorkerLogger? logger, int connectTimeoutMilliseconds) @@ -59,6 +88,12 @@ public sealed class WorkerPipeClient : IWorkerPipeClient { } + /// + /// Initializes a worker pipe client with logger, timeouts, and a session factory. + /// + /// Optional logger for diagnostic output. + /// Overall connection timeout in milliseconds. + /// Factory creating the worker pipe session. public WorkerPipeClient( IWorkerLogger? logger, int connectTimeoutMilliseconds, @@ -71,6 +106,13 @@ public sealed class WorkerPipeClient : IWorkerPipeClient { } + /// + /// Initializes a worker pipe client with full configuration. + /// + /// Optional logger for diagnostic output. + /// Overall connection timeout in milliseconds. + /// Per-attempt connection timeout in milliseconds. + /// Factory creating the worker pipe session. public WorkerPipeClient( IWorkerLogger? logger, int connectTimeoutMilliseconds, @@ -97,6 +139,11 @@ public sealed class WorkerPipeClient : IWorkerPipeClient _connectAttemptTimeoutMilliseconds = connectAttemptTimeoutMilliseconds; } + /// + /// Runs the worker by connecting to the gateway and executing the frame protocol. + /// + /// Worker configuration options. + /// Token to cancel the asynchronous operation. public async Task RunAsync( WorkerOptions options, CancellationToken cancellationToken = default) diff --git a/src/MxGateway.Worker/Ipc/WorkerPipeSession.cs b/src/MxGateway.Worker/Ipc/WorkerPipeSession.cs index a42cc7f..caaa2b3 100644 --- a/src/MxGateway.Worker/Ipc/WorkerPipeSession.cs +++ b/src/MxGateway.Worker/Ipc/WorkerPipeSession.cs @@ -34,6 +34,10 @@ public sealed class WorkerPipeSession private bool _watchdogFaultSent; private bool _shutdownTimedOut; + /// Initializes a new worker pipe session over the provided stream. + /// Network stream for reading and writing frames. + /// Frame protocol configuration. + /// Optional logger for diagnostic output. public WorkerPipeSession( Stream stream, WorkerFrameProtocolOptions options, @@ -49,6 +53,11 @@ public sealed class WorkerPipeSession { } + /// Initializes a new worker pipe session with custom frame reader and writer. + /// Frame reader for incoming messages. + /// Frame writer for outgoing messages. + /// Frame protocol configuration. + /// Function returning the current worker process ID. public WorkerPipeSession( WorkerFrameReader reader, WorkerFrameWriter writer, @@ -65,6 +74,14 @@ public sealed class WorkerPipeSession { } + /// Initializes a new worker pipe session with full configuration and dependencies. + /// Frame reader for incoming messages. + /// Frame writer for outgoing messages. + /// Frame protocol configuration. + /// Function returning the current worker process ID. + /// Session-specific options. + /// Factory creating the MXAccess runtime session. + /// Optional logger for diagnostic output. public WorkerPipeSession( WorkerFrameReader reader, WorkerFrameWriter writer, @@ -84,6 +101,8 @@ public sealed class WorkerPipeSession _sessionOptions.Validate(); } + /// Runs the worker session, completing the handshake and processing messages until cancellation. + /// Token to cancel the asynchronous operation. public async Task RunAsync(CancellationToken cancellationToken = default) { _runtimeSession = _runtimeSessionFactory(); @@ -106,11 +125,16 @@ public sealed class WorkerPipeSession } } + /// Completes the gateway startup handshake using default MXAccess initialization. + /// Token to cancel the asynchronous operation. public Task CompleteStartupHandshakeAsync(CancellationToken cancellationToken = default) { return CompleteStartupHandshakeAsync(InitializeMxAccessAsync, cancellationToken); } + /// Completes the gateway startup handshake with custom MXAccess initialization that returns void. + /// Async function to initialize MXAccess. + /// Token to cancel the asynchronous operation. public async Task CompleteStartupHandshakeAsync( Func initializeMxAccessAsync, CancellationToken cancellationToken = default) @@ -129,6 +153,9 @@ public sealed class WorkerPipeSession cancellationToken).ConfigureAwait(false); } + /// Completes the gateway startup handshake with custom MXAccess initialization that returns WorkerReady. + /// Async function to initialize MXAccess and return ready state. + /// Token to cancel the asynchronous operation. public async Task CompleteStartupHandshakeAsync( Func> initializeMxAccessAsync, CancellationToken cancellationToken = default) diff --git a/src/MxGateway.Worker/Ipc/WorkerPipeSessionOptions.cs b/src/MxGateway.Worker/Ipc/WorkerPipeSessionOptions.cs index 2e60463..4b82ff0 100644 --- a/src/MxGateway.Worker/Ipc/WorkerPipeSessionOptions.cs +++ b/src/MxGateway.Worker/Ipc/WorkerPipeSessionOptions.cs @@ -2,21 +2,28 @@ using System; namespace MxGateway.Worker.Ipc; +/// Configuration options for worker pipe sessions including heartbeat parameters. public sealed class WorkerPipeSessionOptions { + /// Default heartbeat interval (5 seconds). public static readonly TimeSpan DefaultHeartbeatInterval = TimeSpan.FromSeconds(5); + /// Default heartbeat grace period (15 seconds). public static readonly TimeSpan DefaultHeartbeatGrace = TimeSpan.FromSeconds(15); + /// Initializes a new instance of the WorkerPipeSessionOptions class with default values. public WorkerPipeSessionOptions() { HeartbeatInterval = DefaultHeartbeatInterval; HeartbeatGrace = DefaultHeartbeatGrace; } + /// Gets or sets the heartbeat interval. public TimeSpan HeartbeatInterval { get; set; } + /// Gets or sets the heartbeat grace period. public TimeSpan HeartbeatGrace { get; set; } + /// Validates the session options. public void Validate() { if (HeartbeatInterval <= TimeSpan.Zero) diff --git a/src/MxGateway.Worker/MxAccess/IMxAccessComObjectFactory.cs b/src/MxGateway.Worker/MxAccess/IMxAccessComObjectFactory.cs index 20c7aa6..0b435ea 100644 --- a/src/MxGateway.Worker/MxAccess/IMxAccessComObjectFactory.cs +++ b/src/MxGateway.Worker/MxAccess/IMxAccessComObjectFactory.cs @@ -2,5 +2,7 @@ namespace MxGateway.Worker.MxAccess; public interface IMxAccessComObjectFactory { + /// Creates an MXAccess COM object instance. + /// The created COM object. object Create(); } diff --git a/src/MxGateway.Worker/MxAccess/IMxAccessEventSink.cs b/src/MxGateway.Worker/MxAccess/IMxAccessEventSink.cs index dc8e279..80c9545 100644 --- a/src/MxGateway.Worker/MxAccess/IMxAccessEventSink.cs +++ b/src/MxGateway.Worker/MxAccess/IMxAccessEventSink.cs @@ -2,9 +2,13 @@ namespace MxGateway.Worker.MxAccess; public interface IMxAccessEventSink { + /// Attaches the event sink to an MXAccess COM object. + /// The MXAccess COM object. + /// The session ID. void Attach( object mxAccessComObject, string sessionId); + /// Detaches the event sink from the COM object. void Detach(); } diff --git a/src/MxGateway.Worker/MxAccess/IMxAccessServer.cs b/src/MxGateway.Worker/MxAccess/IMxAccessServer.cs index ff506b3..2645e54 100644 --- a/src/MxGateway.Worker/MxAccess/IMxAccessServer.cs +++ b/src/MxGateway.Worker/MxAccess/IMxAccessServer.cs @@ -2,31 +2,57 @@ namespace MxGateway.Worker.MxAccess; public interface IMxAccessServer { + /// Registers a client and returns a server handle. + /// Name of the client requesting registration. + /// Server handle for subsequent operations. int Register(string clientName); + /// Unregisters a server handle. + /// Server handle to unregister. void Unregister(int serverHandle); + /// Adds an item to a server and returns an item handle. + /// Server handle identifying the registration. + /// Item definition string. + /// Item handle for the added item. int AddItem( int serverHandle, string itemDefinition); + /// Adds an item with context to a server and returns an item handle. + /// Server handle identifying the registration. + /// Item definition string. + /// Item context string. + /// Item handle for the added item. int AddItem2( int serverHandle, string itemDefinition, string itemContext); + /// Removes an item from a server. + /// Server handle identifying the registration. + /// Item handle to remove. void RemoveItem( int serverHandle, int itemHandle); + /// Subscribes to change notifications for an item. + /// Server handle identifying the registration. + /// Item handle to subscribe to. void Advise( int serverHandle, int itemHandle); + /// Unsubscribes from change notifications for an item. + /// Server handle identifying the registration. + /// Item handle to unsubscribe from. void UnAdvise( int serverHandle, int itemHandle); + /// Subscribes to supervisory change notifications for an item. + /// Server handle identifying the registration. + /// Item handle to subscribe to. void AdviseSupervisory( int serverHandle, int itemHandle); diff --git a/src/MxGateway.Worker/MxAccess/IWorkerRuntimeSession.cs b/src/MxGateway.Worker/MxAccess/IWorkerRuntimeSession.cs index bf84926..ceb3172 100644 --- a/src/MxGateway.Worker/MxAccess/IWorkerRuntimeSession.cs +++ b/src/MxGateway.Worker/MxAccess/IWorkerRuntimeSession.cs @@ -7,25 +7,65 @@ using MxGateway.Worker.Sta; namespace MxGateway.Worker.MxAccess; +/// +/// Manages the runtime session between the worker and the MXAccess COM instance on an STA thread. +/// public interface IWorkerRuntimeSession : IDisposable { + /// + /// Starts the session, creates the MXAccess COM object, and returns ready metadata. + /// + /// Identifier of the session. + /// ID of the worker process. + /// Token to cancel the asynchronous operation. + /// Asynchronous task returning worker readiness metadata. Task StartAsync( string sessionId, int workerProcessId, CancellationToken cancellationToken = default); + /// + /// Dispatches an STA command to the MXAccess runtime and returns the reply. + /// + /// STA command to execute on the STA thread. + /// Asynchronous task returning the command reply. Task DispatchAsync(StaCommand command); + /// + /// Captures a heartbeat snapshot of the runtime state. + /// WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat(); + /// + /// Drains up to the specified number of pending events from the queue. + /// + /// Maximum number of events to drain. + /// List of drained events. IReadOnlyList DrainEvents(uint maxEvents); + /// + /// Drains a pending fault from the queue, if any. + /// WorkerFault? DrainFault(); + /// + /// Cancels a pending command by correlation ID. + /// + /// Correlation ID of the command to cancel. + /// True if the command was found and cancelled; otherwise, false. bool CancelCommand(string correlationId); + /// + /// Requests a graceful shutdown of the session. + /// void RequestShutdown(); + /// + /// Shuts down the session gracefully within the specified timeout. + /// + /// Maximum time to allow for graceful shutdown. + /// Token to cancel the asynchronous operation. + /// Asynchronous task returning the shutdown result. Task ShutdownGracefullyAsync( TimeSpan timeout, CancellationToken cancellationToken = default); diff --git a/src/MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs b/src/MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs index ef5dbfc..5c5ae69 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs @@ -4,6 +4,7 @@ using Proto = MxGateway.Contracts.Proto; namespace MxGateway.Worker.MxAccess; +/// Sink for MXAccess COM events that converts them to protobuf format. public sealed class MxAccessBaseEventSink : IMxAccessEventSink { private readonly MxAccessEventMapper eventMapper; @@ -11,16 +12,22 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink private LMXProxyServerClass? server; private string sessionId = string.Empty; + /// Initializes a new instance of the MxAccessBaseEventSink class with a default queue. public MxAccessBaseEventSink() : this(new MxAccessEventQueue()) { } + /// Initializes a new instance of the MxAccessBaseEventSink class with a provided queue. + /// Queue for buffering converted MXAccess events. public MxAccessBaseEventSink(MxAccessEventQueue eventQueue) : this(eventQueue, new MxAccessEventMapper()) { } + /// Initializes a new instance of the MxAccessBaseEventSink class with provided queue and mapper. + /// Queue for buffering converted MXAccess events. + /// Converter for MXAccess events to protobuf format. public MxAccessBaseEventSink( MxAccessEventQueue eventQueue, MxAccessEventMapper eventMapper) @@ -29,6 +36,7 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink this.eventMapper = eventMapper ?? throw new ArgumentNullException(nameof(eventMapper)); } + /// public void Attach( object mxAccessComObject, string sessionId) @@ -41,6 +49,7 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink server.OnBufferedDataChange += OnBufferedDataChange; } + /// public void Detach() { if (server is null) diff --git a/src/MxGateway.Worker/MxAccess/MxAccessComObjectFactory.cs b/src/MxGateway.Worker/MxAccess/MxAccessComObjectFactory.cs index ad4187d..ceb0167 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessComObjectFactory.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessComObjectFactory.cs @@ -2,8 +2,10 @@ using ArchestrA.MxAccess; namespace MxGateway.Worker.MxAccess; +/// Factory for creating MXAccess COM objects on the STA thread. public sealed class MxAccessComObjectFactory : IMxAccessComObjectFactory { + /// public object Create() { return new LMXProxyServerClass(); diff --git a/src/MxGateway.Worker/MxAccess/MxAccessComServer.cs b/src/MxGateway.Worker/MxAccess/MxAccessComServer.cs index 4553b7a..f9fad28 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessComServer.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessComServer.cs @@ -5,15 +5,23 @@ using ArchestrA.MxAccess; namespace MxGateway.Worker.MxAccess; +/// +/// Adapter exposing MXAccess COM object methods through the IMxAccessServer interface. +/// public sealed class MxAccessComServer : IMxAccessServer { private readonly object mxAccessComObject; + /// + /// Initializes the adapter with the MXAccess COM object. + /// + /// MXAccess COM object instance. public MxAccessComServer(object mxAccessComObject) { this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject)); } + /// public int Register(string clientName) { if (mxAccessComObject is ILMXProxyServer mxAccessServer) @@ -24,6 +32,7 @@ public sealed class MxAccessComServer : IMxAccessServer return (int)Invoke(nameof(Register), clientName); } + /// public void Unregister(int serverHandle) { if (mxAccessComObject is ILMXProxyServer mxAccessServer) @@ -35,6 +44,7 @@ public sealed class MxAccessComServer : IMxAccessServer Invoke(nameof(Unregister), serverHandle); } + /// public int AddItem( int serverHandle, string itemDefinition) @@ -47,6 +57,7 @@ public sealed class MxAccessComServer : IMxAccessServer return (int)Invoke(nameof(AddItem), serverHandle, itemDefinition); } + /// public int AddItem2( int serverHandle, string itemDefinition, @@ -60,6 +71,7 @@ public sealed class MxAccessComServer : IMxAccessServer return (int)Invoke(nameof(AddItem2), serverHandle, itemDefinition, itemContext); } + /// public void RemoveItem( int serverHandle, int itemHandle) @@ -73,6 +85,7 @@ public sealed class MxAccessComServer : IMxAccessServer Invoke(nameof(RemoveItem), serverHandle, itemHandle); } + /// public void Advise( int serverHandle, int itemHandle) @@ -86,6 +99,7 @@ public sealed class MxAccessComServer : IMxAccessServer Invoke(nameof(Advise), serverHandle, itemHandle); } + /// public void UnAdvise( int serverHandle, int itemHandle) @@ -99,6 +113,7 @@ public sealed class MxAccessComServer : IMxAccessServer Invoke(nameof(UnAdvise), serverHandle, itemHandle); } + /// public void AdviseSupervisory( int serverHandle, int itemHandle) diff --git a/src/MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs b/src/MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs index d2d325b..0ddb00a 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs @@ -6,16 +6,28 @@ using MxGateway.Worker.Sta; namespace MxGateway.Worker.MxAccess; +/// +/// Executes MXAccess commands on an STA session. +/// public sealed class MxAccessCommandExecutor : IStaCommandExecutor { private readonly MxAccessSession session; private readonly VariantConverter variantConverter; + /// + /// Initializes a command executor with an MXAccess session. + /// + /// MXAccess session on the STA thread. public MxAccessCommandExecutor(MxAccessSession session) : this(session, new VariantConverter()) { } + /// + /// Initializes a command executor with an MXAccess session and a variant converter. + /// + /// MXAccess session on the STA thread. + /// Converter for MXAccess variant values to MxValue protobuf messages. public MxAccessCommandExecutor( MxAccessSession session, VariantConverter variantConverter) @@ -24,6 +36,11 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter)); } + /// + /// Executes an MXAccess command and returns the reply. + /// + /// STA command to execute. + /// Command reply with result or error details. public MxCommandReply Execute(StaCommand command) { if (command is null) diff --git a/src/MxGateway.Worker/MxAccess/MxAccessCreationException.cs b/src/MxGateway.Worker/MxAccess/MxAccessCreationException.cs index f0611af..4cfc635 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessCreationException.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessCreationException.cs @@ -3,8 +3,11 @@ using System.Runtime.InteropServices; namespace MxGateway.Worker.MxAccess; +/// Thrown when the worker fails to instantiate the MXAccess COM object. public sealed class MxAccessCreationException : Exception { + /// Initializes a new instance with diagnostic info from the inner exception. + /// The exception that caused the creation failure. public MxAccessCreationException(Exception innerException) : base( $"Failed to create MXAccess COM object {MxAccessInteropInfo.ComClassName} ({MxAccessInteropInfo.ProgId}).", @@ -16,14 +19,21 @@ public sealed class MxAccessCreationException : Exception HResult = innerException.HResult; } + /// The ProgID that was attempted during COM instantiation. public string AttemptedProgId { get; } + /// The CLSID that was attempted during COM instantiation. public string AttemptedClsid { get; } + /// The COM class name that was attempted during instantiation. public string AttemptedComClassName { get; } + /// The captured HResult from the instantiation failure, or null if zero. public int? CapturedHResult => HResult == 0 ? null : HResult; + /// Wraps an exception in MxAccessCreationException if it is not already. + /// The exception to wrap. + /// An MxAccessCreationException wrapping the input exception. public static MxAccessCreationException From(Exception exception) { return exception is MxAccessCreationException creationException @@ -31,6 +41,9 @@ public sealed class MxAccessCreationException : Exception : new MxAccessCreationException(exception); } + /// Extracts the HResult from an exception, handling MXAccess and COM exceptions specially. + /// The exception to extract the HResult from. + /// The HResult value, or null if zero. public static int? ExtractHResult(Exception exception) { if (exception is MxAccessCreationException creationException) diff --git a/src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs b/src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs index 0fe0fef..f6ba9ca 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessEventMapper.cs @@ -4,16 +4,21 @@ using MxGateway.Worker.Conversion; namespace MxGateway.Worker.MxAccess; +/// Maps MXAccess COM events to protobuf MxEvent messages. public sealed class MxAccessEventMapper { private readonly VariantConverter variantConverter; private readonly MxStatusProxyConverter statusProxyConverter; + /// Initializes a new instance of the MxAccessEventMapper class with default converters. public MxAccessEventMapper() : this(new VariantConverter(), new MxStatusProxyConverter()) { } + /// Initializes a new instance of the MxAccessEventMapper class with provided converters. + /// Converter for MXAccess variant values to MxValue protobuf messages. + /// Converter for MXAccess status arrays to MxStatusProxy protobuf messages. public MxAccessEventMapper( VariantConverter variantConverter, MxStatusProxyConverter statusProxyConverter) @@ -22,6 +27,14 @@ public sealed class MxAccessEventMapper this.statusProxyConverter = statusProxyConverter ?? throw new ArgumentNullException(nameof(statusProxyConverter)); } + /// Creates an OnDataChange event from MXAccess COM event arguments. + /// Identifier of the session. + /// Handle returned by the worker. + /// Handle returned by the worker. + /// Item value received from MXAccess. + /// Item quality code from MXAccess. + /// Item timestamp from MXAccess. + /// Array of MxStatusProxy values from MXAccess. public MxEvent CreateOnDataChange( string sessionId, int serverHandle, @@ -45,6 +58,11 @@ public sealed class MxAccessEventMapper return mxEvent; } + /// Creates an OnWriteComplete event from MXAccess COM event arguments. + /// Identifier of the session. + /// Handle returned by the worker. + /// Handle returned by the worker. + /// Array of MxStatusProxy values from MXAccess. public MxEvent CreateOnWriteComplete( string sessionId, int serverHandle, @@ -62,6 +80,11 @@ public sealed class MxAccessEventMapper return mxEvent; } + /// Creates an OperationComplete event from MXAccess COM event arguments. + /// Identifier of the session. + /// Handle returned by the worker. + /// Handle returned by the worker. + /// Array of MxStatusProxy values from MXAccess. public MxEvent CreateOperationComplete( string sessionId, int serverHandle, @@ -79,6 +102,15 @@ public sealed class MxAccessEventMapper return mxEvent; } + /// Creates an OnBufferedDataChange event from MXAccess COM event arguments. + /// Identifier of the session. + /// Handle returned by the worker. + /// Handle returned by the worker. + /// Raw MXAccess data type code for the buffered value. + /// Item value received from MXAccess. + /// Array of quality values from MXAccess. + /// Array of timestamp values from MXAccess. + /// Array of MxStatusProxy values from MXAccess. public MxEvent CreateOnBufferedDataChange( string sessionId, int serverHandle, @@ -108,6 +140,9 @@ public sealed class MxAccessEventMapper return mxEvent; } + /// Maps a raw MXAccess data type code to the MxDataType enum. + /// Raw MXAccess data type value to map. + /// The corresponding MxDataType enum value. public static MxDataType MapMxDataType(int rawDataType) { return rawDataType switch diff --git a/src/MxGateway.Worker/MxAccess/MxAccessEventQueue.cs b/src/MxGateway.Worker/MxAccess/MxAccessEventQueue.cs index 184abd9..b07ad9f 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessEventQueue.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessEventQueue.cs @@ -5,8 +5,14 @@ using MxGateway.Contracts.Proto; namespace MxGateway.Worker.MxAccess; +/// +/// Thread-safe queue for MxAccess events with capacity overflow and fault tracking. +/// public sealed class MxAccessEventQueue { + /// + /// Default queue capacity (10,000 events). + /// public const int DefaultCapacity = 10000; private readonly int capacity; @@ -16,11 +22,18 @@ public sealed class MxAccessEventQueue private WorkerFault? fault; private bool faultDrained; + /// + /// Initializes the queue with the default capacity. + /// public MxAccessEventQueue() : this(DefaultCapacity) { } + /// + /// Initializes the queue with the specified capacity. + /// + /// Maximum number of events the queue can hold. public MxAccessEventQueue(int capacity) { if (capacity <= 0) @@ -34,8 +47,14 @@ public sealed class MxAccessEventQueue events = new Queue(capacity); } + /// + /// The queue's maximum capacity. + /// public int Capacity => capacity; + /// + /// The current number of events in the queue. + /// public int Count { get @@ -47,6 +66,9 @@ public sealed class MxAccessEventQueue } } + /// + /// The highest event sequence number assigned. + /// public ulong LastEventSequence { get @@ -58,6 +80,9 @@ public sealed class MxAccessEventQueue } } + /// + /// Indicates whether the queue is in a faulted state. + /// public bool IsFaulted { get @@ -69,6 +94,9 @@ public sealed class MxAccessEventQueue } } + /// + /// The current fault if the queue is faulted, or null. + /// public WorkerFault? Fault { get @@ -80,6 +108,10 @@ public sealed class MxAccessEventQueue } } + /// + /// Enqueues an MxAccess event, assigning a sequence number and timestamp. + /// + /// MXAccess event to enqueue. public void Enqueue(MxEvent mxEvent) { if (mxEvent is null) @@ -112,6 +144,10 @@ public sealed class MxAccessEventQueue } } + /// + /// Attempts to dequeue the next event without removing it if empty. + /// + /// The dequeued event if successful; null if queue is empty. public bool TryDequeue(out WorkerEvent? workerEvent) { lock (syncRoot) @@ -127,6 +163,10 @@ public sealed class MxAccessEventQueue } } + /// + /// Drains up to maxEvents from the queue; if maxEvents is 0, drains all events. + /// + /// Maximum number of events to drain; 0 means drain all. public IReadOnlyList Drain(uint maxEvents) { lock (syncRoot) @@ -149,6 +189,10 @@ public sealed class MxAccessEventQueue } } + /// + /// Records a fault if one has not already been recorded. + /// + /// Worker fault to record. public void RecordFault(WorkerFault workerFault) { if (workerFault is null) @@ -162,6 +206,9 @@ public sealed class MxAccessEventQueue } } + /// + /// Returns and clears the fault so it is not reported twice. + /// public WorkerFault? DrainFault() { lock (syncRoot) diff --git a/src/MxGateway.Worker/MxAccess/MxAccessEventQueueOverflowException.cs b/src/MxGateway.Worker/MxAccess/MxAccessEventQueueOverflowException.cs index e00de4a..9c48b07 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessEventQueueOverflowException.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessEventQueueOverflowException.cs @@ -4,11 +4,18 @@ namespace MxGateway.Worker.MxAccess; public sealed class MxAccessEventQueueOverflowException : Exception { + /// + /// Initializes a new instance of . + /// + /// Queue capacity. public MxAccessEventQueueOverflowException(int capacity) : base($"MXAccess outbound event queue reached its configured capacity of {capacity}.") { Capacity = capacity; } + /// + /// Gets the queue capacity. + /// public int Capacity { get; } } diff --git a/src/MxGateway.Worker/MxAccess/MxAccessHandleRegistry.cs b/src/MxGateway.Worker/MxAccess/MxAccessHandleRegistry.cs index f6d94b0..88222c9 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessHandleRegistry.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessHandleRegistry.cs @@ -10,17 +10,20 @@ public sealed class MxAccessHandleRegistry private readonly Dictionary itemHandles = new(); private readonly Dictionary adviceHandles = new(); + /// Gets a read-only list of registered server handles ordered by handle value. public IReadOnlyList ServerHandles => serverHandles .Values .OrderBy(handle => handle.ServerHandle) .ToArray(); + /// Gets a read-only list of registered item handles ordered by server handle then item handle. public IReadOnlyList ItemHandles => itemHandles .Values .OrderBy(handle => handle.ServerHandle) .ThenBy(handle => handle.ItemHandle) .ToArray(); + /// Gets a read-only list of registered advice handles ordered by server handle, item handle, and advice kind. public IReadOnlyList AdviceHandles => adviceHandles .Values .OrderBy(handle => handle.ServerHandle) @@ -28,6 +31,9 @@ public sealed class MxAccessHandleRegistry .ThenBy(handle => handle.AdviceKind) .ToArray(); + /// Registers a server handle with the registry. + /// Handle returned by the worker. + /// Display name of the client that owns the server handle. public void RegisterServerHandle( int serverHandle, string clientName) @@ -35,6 +41,8 @@ public sealed class MxAccessHandleRegistry serverHandles[serverHandle] = new RegisteredServerHandle(serverHandle, clientName); } + /// Unregisters a server handle and all associated item and advice handles from the registry. + /// Handle returned by the worker. public void UnregisterServerHandle(int serverHandle) { serverHandles.Remove(serverHandle); @@ -56,11 +64,19 @@ public sealed class MxAccessHandleRegistry } } + /// Checks if the registry contains the specified server handle. + /// Handle returned by the worker. public bool ContainsServerHandle(int serverHandle) { return serverHandles.ContainsKey(serverHandle); } + /// Registers an item handle with the registry. + /// Handle returned by the worker. + /// Handle returned by the worker. + /// Item definition name from MXAccess. + /// Item context from MXAccess, or empty string if none. + /// True if the item has a context; false otherwise. public void RegisterItemHandle( int serverHandle, int itemHandle, @@ -76,6 +92,9 @@ public sealed class MxAccessHandleRegistry hasItemContext); } + /// Removes an item handle and all associated advice handles from the registry. + /// Handle returned by the worker. + /// Handle returned by the worker. public void RemoveItemHandle( int serverHandle, int itemHandle) @@ -84,6 +103,9 @@ public sealed class MxAccessHandleRegistry RemoveAdviceHandles(serverHandle, itemHandle); } + /// Checks if the registry contains the specified item handle. + /// Handle returned by the worker. + /// Handle returned by the worker. public bool ContainsItemHandle( int serverHandle, int itemHandle) @@ -91,6 +113,10 @@ public sealed class MxAccessHandleRegistry return itemHandles.ContainsKey(CreateItemKey(serverHandle, itemHandle)); } + /// Registers an advice handle with the registry. + /// Handle returned by the worker. + /// Handle returned by the worker. + /// Type of advice to register. public void RegisterAdviceHandle( int serverHandle, int itemHandle, @@ -103,6 +129,9 @@ public sealed class MxAccessHandleRegistry adviceKind); } + /// Removes all advice handles for the specified server and item handles from the registry. + /// Handle returned by the worker. + /// Handle returned by the worker. public void RemoveAdviceHandles( int serverHandle, int itemHandle) @@ -116,6 +145,10 @@ public sealed class MxAccessHandleRegistry } } + /// Checks if the registry contains the specified advice handle. + /// Handle returned by the worker. + /// Handle returned by the worker. + /// Type of advice to check. public bool ContainsAdviceHandle( int serverHandle, int itemHandle, @@ -137,6 +170,10 @@ public sealed class MxAccessHandleRegistry private readonly int itemHandle; private readonly MxAccessAdviceKind adviceKind; + /// Initializes a new instance of the struct. + /// Handle returned by the worker. + /// Handle returned by the worker. + /// Type of advice. public AdviceHandleKey( int serverHandle, int itemHandle, @@ -147,6 +184,7 @@ public sealed class MxAccessHandleRegistry this.adviceKind = adviceKind; } + /// public bool Equals(AdviceHandleKey other) { return serverHandle == other.serverHandle @@ -154,11 +192,13 @@ public sealed class MxAccessHandleRegistry && adviceKind == other.adviceKind; } + /// public override bool Equals(object? obj) { return obj is AdviceHandleKey other && Equals(other); } + /// public override int GetHashCode() { unchecked diff --git a/src/MxGateway.Worker/MxAccess/MxAccessInteropInfo.cs b/src/MxGateway.Worker/MxAccess/MxAccessInteropInfo.cs index ab3efc9..c71ea8e 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessInteropInfo.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessInteropInfo.cs @@ -3,25 +3,52 @@ using ArchestrA.MxAccess; namespace MxGateway.Worker.MxAccess; +/// +/// Constants and metadata for MXAccess COM interop. +/// public static class MxAccessInteropInfo { + /// + /// Versioned ProgID for the MXAccess COM server. + /// public const string ProgId = "LMXProxy.LMXProxyServer.1"; + /// + /// Version-independent ProgID for the MXAccess COM server. + /// public const string VersionIndependentProgId = "LMXProxy.LMXProxyServer"; + /// + /// Class ID (CLSID) of the MXAccess COM server. + /// public const string Clsid = "{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}"; + /// + /// Path to the ArchestrA.MxAccess.dll interop assembly. + /// public const string InteropAssemblyPath = @"C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll"; + /// + /// Path to the installed MXAccess COM server DLL. + /// public const string RegisteredServerPath = @"C:\Program Files (x86)\ArchestrA\Framework\Bin\LmxProxy.dll"; + /// + /// Full qualified name of the COM class. + /// public const string ComClassName = "ArchestrA.MxAccess.LMXProxyServerClass"; + /// + /// Name of the MXAccess interop assembly. + /// public static string InteropAssemblyName => typeof(LMXProxyServerClass).Assembly.GetName().Name ?? string.Empty; + /// + /// Version of the MXAccess interop assembly. + /// public static Version InteropAssemblyVersion => typeof(LMXProxyServerClass).Assembly.GetName().Version ?? new Version(0, 0); } diff --git a/src/MxGateway.Worker/MxAccess/MxAccessSession.cs b/src/MxGateway.Worker/MxAccess/MxAccessSession.cs index 102ed1f..965bdfb 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessSession.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessSession.cs @@ -28,10 +28,14 @@ public sealed class MxAccessSession : IDisposable CreationThreadId = creationThreadId; } + /// The thread ID where this session was created. public int CreationThreadId { get; } + /// The registry for tracking opened handles. public MxAccessHandleRegistry HandleRegistry => handleRegistry; + /// Creates a WorkerReady message with session metadata. + /// Process ID of the worker. public WorkerReady CreateWorkerReady(int workerProcessId) { return new WorkerReady @@ -43,6 +47,10 @@ public sealed class MxAccessSession : IDisposable }; } + /// Creates and initializes an MXAccess COM session. + /// Factory to create the MXAccess COM object. + /// Event sink to attach to the COM object. + /// Identifier of the session. public static MxAccessSession Create( IMxAccessComObjectFactory factory, IMxAccessEventSink eventSink, @@ -97,6 +105,8 @@ public sealed class MxAccessSession : IDisposable } } + /// Registers a client with MXAccess and returns the server handle. + /// Name of the client to register. public int Register(string clientName) { ThrowIfDisposed(); @@ -107,6 +117,8 @@ public sealed class MxAccessSession : IDisposable return serverHandle; } + /// Unregisters a client from MXAccess. + /// Handle returned by the worker. public void Unregister(int serverHandle) { ThrowIfDisposed(); @@ -115,6 +127,9 @@ public sealed class MxAccessSession : IDisposable handleRegistry.UnregisterServerHandle(serverHandle); } + /// Adds an item to an MXAccess server and returns the item handle. + /// Handle returned by the worker. + /// Definition or address of the item to add. public int AddItem( int serverHandle, string itemDefinition) @@ -132,6 +147,10 @@ public sealed class MxAccessSession : IDisposable return itemHandle; } + /// Adds an item with context to an MXAccess server and returns the item handle. + /// Handle returned by the worker. + /// Definition or address of the item to add. + /// Context string for the item. public int AddItem2( int serverHandle, string itemDefinition, @@ -150,6 +169,9 @@ public sealed class MxAccessSession : IDisposable return itemHandle; } + /// Removes an item from an MXAccess server. + /// Handle returned by the worker. + /// Handle returned by the worker. public void RemoveItem( int serverHandle, int itemHandle) @@ -160,6 +182,9 @@ public sealed class MxAccessSession : IDisposable handleRegistry.RemoveItemHandle(serverHandle, itemHandle); } + /// Advises on item changes with plain subscription. + /// Handle returned by the worker. + /// Handle returned by the worker. public void Advise( int serverHandle, int itemHandle) @@ -173,6 +198,9 @@ public sealed class MxAccessSession : IDisposable MxAccessAdviceKind.Plain); } + /// Removes plain advice subscription from an item. + /// Handle returned by the worker. + /// Handle returned by the worker. public void UnAdvise( int serverHandle, int itemHandle) @@ -183,6 +211,9 @@ public sealed class MxAccessSession : IDisposable handleRegistry.RemoveAdviceHandles(serverHandle, itemHandle); } + /// Advises on item changes with supervisory subscription. + /// Handle returned by the worker. + /// Handle returned by the worker. public void AdviseSupervisory( int serverHandle, int itemHandle) @@ -196,6 +227,9 @@ public sealed class MxAccessSession : IDisposable MxAccessAdviceKind.Supervisory); } + /// Adds multiple items in bulk, returning success/failure results. + /// Handle returned by the worker. + /// Enumerable of item definitions to add. public IReadOnlyList AddItemBulk( int serverHandle, IEnumerable tagAddresses) @@ -229,6 +263,9 @@ public sealed class MxAccessSession : IDisposable return results; } + /// Advises on multiple items in bulk, returning success/failure results. + /// Handle returned by the worker. + /// Enumerable of item handles to advise on. public IReadOnlyList AdviseItemBulk( int serverHandle, IEnumerable itemHandles) @@ -256,6 +293,9 @@ public sealed class MxAccessSession : IDisposable return results; } + /// Removes multiple items in bulk, returning success/failure results. + /// Handle returned by the worker. + /// Enumerable of item handles to remove. public IReadOnlyList RemoveItemBulk( int serverHandle, IEnumerable itemHandles) @@ -283,6 +323,9 @@ public sealed class MxAccessSession : IDisposable return results; } + /// Removes advice subscriptions from multiple items in bulk, returning success/failure results. + /// Handle returned by the worker. + /// Enumerable of item handles to unadvise. public IReadOnlyList UnAdviseItemBulk( int serverHandle, IEnumerable itemHandles) @@ -310,6 +353,9 @@ public sealed class MxAccessSession : IDisposable return results; } + /// Adds multiple items and subscribes to them in bulk, returning success/failure results. + /// Handle returned by the worker. + /// Enumerable of item definitions to add and subscribe to. public IReadOnlyList SubscribeBulk( int serverHandle, IEnumerable tagAddresses) @@ -351,6 +397,9 @@ public sealed class MxAccessSession : IDisposable return results; } + /// Unsubscribes from multiple items in bulk, returning success/failure results. + /// Handle returned by the worker. + /// Enumerable of item handles to unsubscribe from. public IReadOnlyList UnsubscribeBulk( int serverHandle, IEnumerable itemHandles) @@ -392,6 +441,7 @@ public sealed class MxAccessSession : IDisposable return results; } + /// Gracefully shuts down the session, cleaning up all handles. public MxAccessShutdownResult ShutdownGracefully() { if (disposed) @@ -409,6 +459,7 @@ public sealed class MxAccessSession : IDisposable return new MxAccessShutdownResult(failures); } + /// Releases the MXAccess COM object and resources. public void Dispose() { if (disposed) diff --git a/src/MxGateway.Worker/MxAccess/MxAccessShutdownFailure.cs b/src/MxGateway.Worker/MxAccess/MxAccessShutdownFailure.cs index f4891f7..a3e377a 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessShutdownFailure.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessShutdownFailure.cs @@ -2,8 +2,18 @@ using System; namespace MxGateway.Worker.MxAccess; +/// +/// Captures details about an MXAccess operation that failed during shutdown. +/// public sealed class MxAccessShutdownFailure { + /// + /// Initializes the shutdown failure record. + /// + /// Name of the operation that failed. + /// Server handle if applicable. + /// Item handle if applicable. + /// Exception that was raised. public MxAccessShutdownFailure( string operation, int? serverHandle, @@ -22,13 +32,28 @@ public sealed class MxAccessShutdownFailure HResult = exception?.HResult; } + /// + /// The operation that failed (e.g., Unregister, RemoveItem). + /// public string Operation { get; } + /// + /// Server handle if applicable; otherwise null. + /// public int? ServerHandle { get; } + /// + /// Item handle if applicable; otherwise null. + /// public int? ItemHandle { get; } + /// + /// Full type name of the exception, or empty string if no exception. + /// public string ExceptionType { get; } + /// + /// HResult code if the exception has one; otherwise null. + /// public int? HResult { get; } } diff --git a/src/MxGateway.Worker/MxAccess/MxAccessShutdownResult.cs b/src/MxGateway.Worker/MxAccess/MxAccessShutdownResult.cs index 683e65f..b992a0c 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessShutdownResult.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessShutdownResult.cs @@ -5,12 +5,16 @@ namespace MxGateway.Worker.MxAccess; public sealed class MxAccessShutdownResult { + /// Initializes a new instance of the class. + /// List of failures encountered during graceful shutdown. public MxAccessShutdownResult(IReadOnlyList failures) { Failures = failures ?? throw new ArgumentNullException(nameof(failures)); } + /// Gets the list of shutdown failures. public IReadOnlyList Failures { get; } + /// Gets a value indicating whether the shutdown succeeded with no failures. public bool Succeeded => Failures.Count == 0; } diff --git a/src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs b/src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs index c5e3e0b..4dbf56e 100644 --- a/src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs +++ b/src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs @@ -18,6 +18,9 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession private MxAccessSession? session; private bool disposed; + /// + /// Initializes a new instance of with default dependencies. + /// public MxAccessStaSession() : this( new StaRuntime(), @@ -26,6 +29,12 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession { } + /// + /// Initializes a new instance of with custom STA runtime and factory. + /// + /// STA thread runtime. + /// MXAccess COM object factory. + /// Event sink for MXAccess events. public MxAccessStaSession( StaRuntime staRuntime, IMxAccessComObjectFactory factory, @@ -34,6 +43,12 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession { } + /// + /// Initializes a new instance of with custom event queue. + /// + /// STA thread runtime. + /// MXAccess COM object factory. + /// Event queue for buffering MXAccess events. public MxAccessStaSession( StaRuntime staRuntime, IMxAccessComObjectFactory factory, @@ -42,6 +57,13 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession { } + /// + /// Initializes a new instance of with all dependencies. + /// + /// STA thread runtime. + /// MXAccess COM object factory. + /// Event sink for MXAccess events. + /// Event queue for buffering MXAccess events. public MxAccessStaSession( StaRuntime staRuntime, IMxAccessComObjectFactory factory, @@ -54,8 +76,17 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue)); } + /// + /// Gets the event queue for this session. + /// public MxAccessEventQueue EventQueue => eventQueue; + /// + /// Starts the MXAccess COM session asynchronously. + /// + /// Worker process identifier. + /// Cancellation token. + /// Worker ready message. public Task StartAsync( int workerProcessId, CancellationToken cancellationToken = default) @@ -63,6 +94,13 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession return StartAsync(string.Empty, workerProcessId, cancellationToken); } + /// + /// Starts the MXAccess COM session with a session ID asynchronously. + /// + /// Session identifier. + /// Worker process identifier. + /// Cancellation token. + /// Worker ready message. public Task StartAsync( string sessionId, int workerProcessId, @@ -88,6 +126,11 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession cancellationToken); } + /// + /// Dispatches a command to the STA thread for execution asynchronously. + /// + /// The command to dispatch. + /// Command reply. public Task DispatchAsync(StaCommand command) { if (commandDispatcher is null) @@ -98,6 +141,10 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession return commandDispatcher.DispatchAsync(command); } + /// + /// Captures a heartbeat snapshot of the session's runtime state. + /// + /// Heartbeat snapshot. public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat() { uint pendingCommandCount = 0; @@ -117,26 +164,48 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession currentCommandCorrelationId); } + /// + /// Requests graceful shutdown of the command dispatcher. + /// public void RequestShutdown() { commandDispatcher?.RequestShutdown(); } + /// + /// Drains up to the specified number of events from the queue. + /// + /// Maximum events to drain. + /// Drained events. public IReadOnlyList DrainEvents(uint maxEvents) { return eventQueue.Drain(maxEvents); } + /// + /// Drains a fault from the queue if present. + /// + /// Drained fault or null. public WorkerFault? DrainFault() { return eventQueue.DrainFault(); } + /// + /// Cancels a queued command by correlation ID. + /// + /// Correlation ID of the command to cancel. + /// True if cancelled; otherwise false. public bool CancelCommand(string correlationId) { return commandDispatcher?.CancelQueuedCommand(correlationId) ?? false; } + /// + /// Gets the registered server handles asynchronously. + /// + /// Cancellation token. + /// Registered server handles. public Task> GetRegisteredServerHandlesAsync( CancellationToken cancellationToken = default) { @@ -150,6 +219,11 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession cancellationToken); } + /// + /// Gets the registered item handles asynchronously. + /// + /// Cancellation token. + /// Registered item handles. public Task> GetRegisteredItemHandlesAsync( CancellationToken cancellationToken = default) { @@ -163,6 +237,11 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession cancellationToken); } + /// + /// Gets the registered advice handles asynchronously. + /// + /// Cancellation token. + /// Registered advice handles. public Task> GetRegisteredAdviceHandlesAsync( CancellationToken cancellationToken = default) { @@ -176,6 +255,12 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession cancellationToken); } + /// + /// Performs graceful shutdown of the MXAccess session within a timeout. + /// + /// Maximum time allowed for shutdown. + /// Cancellation token. + /// Shutdown result with any cleanup failures. public async Task ShutdownGracefullyAsync( TimeSpan timeout, CancellationToken cancellationToken = default) @@ -238,6 +323,7 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession return result; } + /// Releases resources and shuts down the session. public void Dispose() { if (disposed) diff --git a/src/MxGateway.Worker/MxAccess/RegisteredAdviceHandle.cs b/src/MxGateway.Worker/MxAccess/RegisteredAdviceHandle.cs index 0d3e280..f07301f 100644 --- a/src/MxGateway.Worker/MxAccess/RegisteredAdviceHandle.cs +++ b/src/MxGateway.Worker/MxAccess/RegisteredAdviceHandle.cs @@ -2,6 +2,10 @@ namespace MxGateway.Worker.MxAccess; public sealed class RegisteredAdviceHandle { + /// Initializes a new instance of the class. + /// Handle returned by the worker. + /// Handle returned by the worker. + /// Type of advice. public RegisteredAdviceHandle( int serverHandle, int itemHandle, @@ -12,9 +16,12 @@ public sealed class RegisteredAdviceHandle AdviceKind = adviceKind; } + /// Gets the server handle. public int ServerHandle { get; } + /// Gets the item handle. public int ItemHandle { get; } + /// Gets the advice kind. public MxAccessAdviceKind AdviceKind { get; } } diff --git a/src/MxGateway.Worker/MxAccess/RegisteredItemHandle.cs b/src/MxGateway.Worker/MxAccess/RegisteredItemHandle.cs index 15267d9..e62d496 100644 --- a/src/MxGateway.Worker/MxAccess/RegisteredItemHandle.cs +++ b/src/MxGateway.Worker/MxAccess/RegisteredItemHandle.cs @@ -1,7 +1,18 @@ namespace MxGateway.Worker.MxAccess; +/// +/// Metadata for an item handle registered in an MXAccess session. +/// public sealed class RegisteredItemHandle { + /// + /// Initializes a registered item handle with complete metadata. + /// + /// Handle returned by Register. + /// Handle returned by AddItem. + /// Item definition (tag address). + /// Item context string. + /// Whether this item has an associated context. public RegisteredItemHandle( int serverHandle, int itemHandle, @@ -16,13 +27,28 @@ public sealed class RegisteredItemHandle HasItemContext = hasItemContext; } + /// + /// Gets the server handle that owns this item. + /// public int ServerHandle { get; } + /// + /// Gets the item handle within the server. + /// public int ItemHandle { get; } + /// + /// Gets the item definition (tag address). + /// public string ItemDefinition { get; } + /// + /// Gets the item context. + /// public string ItemContext { get; } + /// + /// Gets a value indicating whether this item has an associated context. + /// public bool HasItemContext { get; } } diff --git a/src/MxGateway.Worker/MxAccess/RegisteredServerHandle.cs b/src/MxGateway.Worker/MxAccess/RegisteredServerHandle.cs index 1560970..59011ce 100644 --- a/src/MxGateway.Worker/MxAccess/RegisteredServerHandle.cs +++ b/src/MxGateway.Worker/MxAccess/RegisteredServerHandle.cs @@ -2,6 +2,9 @@ namespace MxGateway.Worker.MxAccess; public sealed class RegisteredServerHandle { + /// Initializes a new registered server handle. + /// MXAccess server handle. + /// Client name associated with the handle. public RegisteredServerHandle( int serverHandle, string clientName) @@ -10,7 +13,9 @@ public sealed class RegisteredServerHandle ClientName = clientName; } + /// The MXAccess server handle. public int ServerHandle { get; } + /// The client name associated with this server handle. public string ClientName { get; } } diff --git a/src/MxGateway.Worker/MxAccess/WorkerRuntimeHeartbeatSnapshot.cs b/src/MxGateway.Worker/MxAccess/WorkerRuntimeHeartbeatSnapshot.cs index cb4eba5..3fe7011 100644 --- a/src/MxGateway.Worker/MxAccess/WorkerRuntimeHeartbeatSnapshot.cs +++ b/src/MxGateway.Worker/MxAccess/WorkerRuntimeHeartbeatSnapshot.cs @@ -4,6 +4,12 @@ namespace MxGateway.Worker.MxAccess; public sealed class WorkerRuntimeHeartbeatSnapshot { + /// Initializes a new instance of the class. + /// Timestamp of the last STA thread activity in UTC. + /// Number of commands awaiting processing. + /// Current depth of the worker event queue. + /// Sequence number of the most recent event. + /// Correlation ID of the in-flight command. public WorkerRuntimeHeartbeatSnapshot( DateTimeOffset lastStaActivityUtc, uint pendingCommandCount, @@ -18,13 +24,18 @@ public sealed class WorkerRuntimeHeartbeatSnapshot CurrentCommandCorrelationId = currentCommandCorrelationId ?? string.Empty; } + /// Gets the last STA activity timestamp in UTC. public DateTimeOffset LastStaActivityUtc { get; } + /// Gets the pending command count. public uint PendingCommandCount { get; } + /// Gets the current depth of the worker event queue. public uint OutboundEventQueueDepth { get; } + /// Gets the sequence number of the most recent event. public ulong LastEventSequence { get; } + /// Gets the correlation ID of the in-flight command. public string CurrentCommandCorrelationId { get; } } diff --git a/src/MxGateway.Worker/Sta/IStaComApartmentInitializer.cs b/src/MxGateway.Worker/Sta/IStaComApartmentInitializer.cs index 782a8eb..38f25a5 100644 --- a/src/MxGateway.Worker/Sta/IStaComApartmentInitializer.cs +++ b/src/MxGateway.Worker/Sta/IStaComApartmentInitializer.cs @@ -1,8 +1,17 @@ namespace MxGateway.Worker.Sta; +/// +/// Initializes and uninitializes the COM apartment for the STA thread. +/// public interface IStaComApartmentInitializer { + /// + /// Initializes the COM apartment on the STA thread. + /// void Initialize(); + /// + /// Uninitializes the COM apartment on the STA thread. + /// void Uninitialize(); } diff --git a/src/MxGateway.Worker/Sta/IStaCommandExecutor.cs b/src/MxGateway.Worker/Sta/IStaCommandExecutor.cs index f47a2c8..5c649e5 100644 --- a/src/MxGateway.Worker/Sta/IStaCommandExecutor.cs +++ b/src/MxGateway.Worker/Sta/IStaCommandExecutor.cs @@ -4,5 +4,8 @@ namespace MxGateway.Worker.Sta; public interface IStaCommandExecutor { + /// Executes a command on the STA thread. + /// The command to execute. + /// The command reply. MxCommandReply Execute(StaCommand command); } diff --git a/src/MxGateway.Worker/Sta/IStaWorkItem.cs b/src/MxGateway.Worker/Sta/IStaWorkItem.cs index 735385a..ae1d735 100644 --- a/src/MxGateway.Worker/Sta/IStaWorkItem.cs +++ b/src/MxGateway.Worker/Sta/IStaWorkItem.cs @@ -2,7 +2,9 @@ namespace MxGateway.Worker.Sta; internal interface IStaWorkItem { + /// Cancels the work item before it executes on the STA thread. void CancelBeforeExecution(); + /// Executes the work item on the STA thread. void Execute(); } diff --git a/src/MxGateway.Worker/Sta/StaComApartmentInitializer.cs b/src/MxGateway.Worker/Sta/StaComApartmentInitializer.cs index 2f6b2e0..c605bce 100644 --- a/src/MxGateway.Worker/Sta/StaComApartmentInitializer.cs +++ b/src/MxGateway.Worker/Sta/StaComApartmentInitializer.cs @@ -9,6 +9,7 @@ public sealed class StaComApartmentInitializer : IStaComApartmentInitializer private const int SOk = 0; private const int SFalse = 1; + /// Initializes the COM apartment in single-threaded mode. public void Initialize() { int hresult = CoInitializeEx(IntPtr.Zero, CoInitializeApartmentThreaded); @@ -18,6 +19,7 @@ public sealed class StaComApartmentInitializer : IStaComApartmentInitializer } } + /// Uninitializes the COM apartment. public void Uninitialize() { CoUninitialize(); diff --git a/src/MxGateway.Worker/Sta/StaCommand.cs b/src/MxGateway.Worker/Sta/StaCommand.cs index 46f95d6..3ae977b 100644 --- a/src/MxGateway.Worker/Sta/StaCommand.cs +++ b/src/MxGateway.Worker/Sta/StaCommand.cs @@ -7,6 +7,12 @@ namespace MxGateway.Worker.Sta; public sealed class StaCommand { + /// Initializes a new instance of the class. + /// Identifier of the session. + /// Correlation identifier for the command. + /// The MXAccess command to execute. + /// Timestamp when the command was enqueued. + /// Token to cancel the asynchronous operation. public StaCommand( string sessionId, string correlationId, @@ -31,17 +37,24 @@ public sealed class StaCommand CancellationToken = cancellationToken; } + /// Gets the session ID for the STA command. public string SessionId { get; } + /// Gets the correlation ID for the STA command. public string CorrelationId { get; } + /// Gets the MXAccess command to execute. public MxCommand Command { get; } + /// Gets the timestamp when the command was enqueued. public Timestamp EnqueueTimestamp { get; } + /// Gets the token to cancel the asynchronous operation. public CancellationToken CancellationToken { get; } + /// Gets the kind of the MXAccess command. public MxCommandKind Kind => Command.Kind; + /// Gets the method name of the command. public string MethodName => Kind.ToString(); } diff --git a/src/MxGateway.Worker/Sta/StaCommandDispatcher.cs b/src/MxGateway.Worker/Sta/StaCommandDispatcher.cs index 575e7f2..1a822cb 100644 --- a/src/MxGateway.Worker/Sta/StaCommandDispatcher.cs +++ b/src/MxGateway.Worker/Sta/StaCommandDispatcher.cs @@ -20,6 +20,11 @@ public sealed class StaCommandDispatcher private bool shutdownRequested; private string currentCommandCorrelationId = string.Empty; + /// + /// Initializes a new instance of with default converter. + /// + /// STA thread runtime. + /// Command executor. public StaCommandDispatcher( StaRuntime staRuntime, IStaCommandExecutor commandExecutor) @@ -27,6 +32,12 @@ public sealed class StaCommandDispatcher { } + /// + /// Initializes a new instance of with custom converter. + /// + /// STA thread runtime. + /// Command executor. + /// HResult converter. public StaCommandDispatcher( StaRuntime staRuntime, IStaCommandExecutor commandExecutor, @@ -35,6 +46,13 @@ public sealed class StaCommandDispatcher { } + /// + /// Initializes a new instance of with all parameters. + /// + /// STA thread runtime. + /// Command executor. + /// HResult converter. + /// Maximum pending commands allowed. public StaCommandDispatcher( StaRuntime staRuntime, IStaCommandExecutor commandExecutor, @@ -54,6 +72,9 @@ public sealed class StaCommandDispatcher this.maxPendingCommands = maxPendingCommands; } + /// + /// Gets the count of pending commands in the queue. + /// public int PendingCommandCount { get @@ -65,6 +86,9 @@ public sealed class StaCommandDispatcher } } + /// + /// Gets the correlation ID of the currently executing command. + /// public string CurrentCommandCorrelationId { get @@ -76,6 +100,11 @@ public sealed class StaCommandDispatcher } } + /// + /// Dispatches a command to the queue for asynchronous STA execution. + /// + /// The command to dispatch. + /// Task for the command reply. public Task DispatchAsync(StaCommand command) { if (command is null) @@ -114,6 +143,11 @@ public sealed class StaCommandDispatcher } } + /// + /// Cancels a queued command by its correlation ID. + /// + /// Correlation ID of the command to cancel. + /// True if the command was canceled; otherwise false. public bool CancelQueuedCommand(string correlationId) { if (string.IsNullOrWhiteSpace(correlationId)) @@ -159,6 +193,9 @@ public sealed class StaCommandDispatcher } } + /// + /// Requests graceful shutdown, rejecting all queued commands. + /// public void RequestShutdown() { lock (gate) @@ -175,6 +212,10 @@ public sealed class StaCommandDispatcher } } + /// + /// Populates the given heartbeat with current dispatcher state. + /// + /// Heartbeat to populate. public void PopulateHeartbeat(WorkerHeartbeat heartbeat) { if (heartbeat is null) @@ -331,15 +372,29 @@ public sealed class StaCommandDispatcher private readonly TaskCompletionSource completion = new( TaskCreationOptions.RunContinuationsAsynchronously); + /// + /// Initializes a new instance of . + /// + /// The STA command to queue. public QueuedStaCommand(StaCommand command) { Command = command; } + /// + /// Gets the queued STA command. + /// public StaCommand Command { get; } + /// + /// Gets the task representing the command's completion. + /// public Task Task => completion.Task; + /// + /// Completes the command with the given reply. + /// + /// The command reply. public void Complete(MxCommandReply reply) { completion.TrySetResult(reply); diff --git a/src/MxGateway.Worker/Sta/StaMessagePump.cs b/src/MxGateway.Worker/Sta/StaMessagePump.cs index e0a0f21..a06cd6c 100644 --- a/src/MxGateway.Worker/Sta/StaMessagePump.cs +++ b/src/MxGateway.Worker/Sta/StaMessagePump.cs @@ -5,6 +5,7 @@ using Microsoft.Win32.SafeHandles; namespace MxGateway.Worker.Sta; +/// Pumps Windows messages on the STA thread to allow MXAccess COM events to deliver. public sealed class StaMessagePump { private const uint Infinite = 0xFFFFFFFF; @@ -13,6 +14,9 @@ public sealed class StaMessagePump private const uint PmRemove = 0x0001; private const uint QsAllInput = 0x04FF; + /// Waits for a command wake event or Windows messages, pumping any pending messages. + /// Event to signal when work is available. + /// Maximum time to wait; InfiniteTimeSpan waits indefinitely. public void WaitForWorkOrMessages(WaitHandle commandWakeEvent, TimeSpan timeout) { if (commandWakeEvent is null) @@ -38,6 +42,7 @@ public sealed class StaMessagePump } } + /// Pumps and dispatches all pending Windows messages, returning the count processed. public int PumpPendingMessages() { int pumpedMessages = 0; diff --git a/src/MxGateway.Worker/Sta/StaRuntime.cs b/src/MxGateway.Worker/Sta/StaRuntime.cs index 7d88401..07a11ee 100644 --- a/src/MxGateway.Worker/Sta/StaRuntime.cs +++ b/src/MxGateway.Worker/Sta/StaRuntime.cs @@ -23,11 +23,20 @@ public sealed class StaRuntime : IDisposable private long lastActivityUtcTicks; private bool comInitialized; + /// + /// Initializes a new instance of with default dependencies. + /// public StaRuntime() : this(new StaComApartmentInitializer(), new StaMessagePump(), TimeSpan.FromMilliseconds(50)) { } + /// + /// Initializes a new instance of with custom dependencies. + /// + /// COM apartment initializer. + /// Message pump for the STA thread. + /// Interval for idle message pump waits. public StaRuntime( IStaComApartmentInitializer comApartmentInitializer, StaMessagePump messagePump, @@ -54,13 +63,25 @@ public sealed class StaRuntime : IDisposable staThread.SetApartmentState(ApartmentState.STA); } + /// + /// Gets the managed thread ID of the STA thread. + /// public int? StaThreadId { get; private set; } + /// + /// Gets the timestamp of the last STA thread activity. + /// public DateTimeOffset LastActivityUtc => new(new DateTime(Volatile.Read(ref lastActivityUtcTicks), DateTimeKind.Utc)); + /// + /// Gets a value indicating whether the STA runtime is currently running. + /// public bool IsRunning => startedEvent.IsSet && !stoppedEvent.IsSet; + /// + /// Starts the STA thread. + /// public void Start() { ThrowIfDisposed(); @@ -88,6 +109,12 @@ public sealed class StaRuntime : IDisposable } } + /// + /// Invokes an action on the STA thread asynchronously. + /// + /// Action to invoke on the STA thread. + /// Cancellation token. + /// Task that completes when the action executes. public Task InvokeAsync(Action command, CancellationToken cancellationToken = default) { if (command is null) @@ -104,6 +131,13 @@ public sealed class StaRuntime : IDisposable cancellationToken); } + /// + /// Invokes a function on the STA thread asynchronously. + /// + /// Return type of the function. + /// Function to invoke on the STA thread. + /// Cancellation token. + /// Task that returns the function result. public Task InvokeAsync(Func command, CancellationToken cancellationToken = default) { if (command is null) @@ -135,6 +169,11 @@ public sealed class StaRuntime : IDisposable return workItem.Task; } + /// + /// Requests graceful shutdown of the STA runtime within a timeout. + /// + /// Maximum time to wait for shutdown. + /// True if shutdown completed; otherwise false. public bool Shutdown(TimeSpan timeout) { if (timeout < TimeSpan.Zero && timeout != Timeout.InfiniteTimeSpan) @@ -165,6 +204,9 @@ public sealed class StaRuntime : IDisposable return stopped; } + /// + /// Releases resources used by the STA runtime. + /// public void Dispose() { if (disposed) diff --git a/src/MxGateway.Worker/Sta/StaWorkItem.cs b/src/MxGateway.Worker/Sta/StaWorkItem.cs index 3a7f8e0..08d2ec2 100644 --- a/src/MxGateway.Worker/Sta/StaWorkItem.cs +++ b/src/MxGateway.Worker/Sta/StaWorkItem.cs @@ -4,6 +4,9 @@ using System.Threading.Tasks; namespace MxGateway.Worker.Sta; +/// +/// Encapsulates a work item to be executed on an STA thread with cancellation support. +/// internal sealed class StaWorkItem : IStaWorkItem { private readonly Func command; @@ -11,6 +14,9 @@ internal sealed class StaWorkItem : IStaWorkItem private readonly CancellationTokenRegistration cancellationRegistration; private int started; + /// Initializes a work item with a command and cancellation token. + /// Function to execute on the STA thread. + /// Token to cancel the work item. public StaWorkItem(Func command, CancellationToken cancellationToken) { this.command = command ?? throw new ArgumentNullException(nameof(command)); @@ -30,10 +36,12 @@ internal sealed class StaWorkItem : IStaWorkItem } } + /// Gets the task that completes when work completes. public Task Task => Completion.Task; private TaskCompletionSource Completion { get; } + /// Cancels the work item before execution begins. public void CancelBeforeExecution() { if (Interlocked.CompareExchange(ref started, 1, 0) == 0) @@ -43,6 +51,7 @@ internal sealed class StaWorkItem : IStaWorkItem } } + /// Executes the work item command. public void Execute() { if (Interlocked.CompareExchange(ref started, 1, 0) != 0) diff --git a/src/MxGateway.Worker/WorkerApplication.cs b/src/MxGateway.Worker/WorkerApplication.cs index db4bc4b..fec84c8 100644 --- a/src/MxGateway.Worker/WorkerApplication.cs +++ b/src/MxGateway.Worker/WorkerApplication.cs @@ -6,8 +6,11 @@ using MxGateway.Worker.Ipc; namespace MxGateway.Worker; +/// Entry point for the worker process. public static class WorkerApplication { + /// Initializes and runs the worker with default environment and logging. + /// Command-line arguments. public static int Run(string[] args) { return Run( @@ -16,6 +19,10 @@ public static class WorkerApplication new WorkerConsoleLogger(Console.Error)); } + /// Initializes and runs the worker with custom environment and logging. + /// Command-line arguments. + /// Worker environment for resolving configuration. + /// Worker logger for diagnostics. public static int Run( string[] args, IWorkerEnvironment environment, @@ -28,6 +35,11 @@ public static class WorkerApplication new WorkerPipeClient(logger)); } + /// Parses arguments, bootstraps the handshake, and runs the worker until shutdown. + /// Command-line arguments. + /// Worker environment for resolving configuration. + /// Worker logger for diagnostics. + /// Named pipe client for gateway communication. public static int Run( string[] args, IWorkerEnvironment environment,