fix(dotnet-client): no ack retry, caller-owned channels, safe session dispose, typed diagnostics, AttachSession

This commit is contained in:
Joseph Doherty
2026-10-22 06:09:25 -04:00
parent 488649ce99
commit 6f0b408833
6 changed files with 495 additions and 27 deletions
@@ -1,3 +1,4 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
@@ -1452,6 +1453,106 @@ public sealed class MxGatewaySession : IAsyncDisposable
return _client.InvokeAsync(request, cancellationToken);
}
/// <summary>
/// Sends a PING diagnostic command to the worker and throws if the reply is a failure.
/// </summary>
/// <param name="message">Optional message echoed to the worker; defaults to empty.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that completes when the worker acknowledges the ping.</returns>
public async Task PingAsync(
string? message = null,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Ping,
Ping = new PingCommand { Message = message ?? string.Empty },
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
/// <summary>
/// Gets the gateway's lifecycle state for this session.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The current session state.</returns>
public async Task<SessionState> GetSessionStateAsync(CancellationToken cancellationToken = default)
{
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.GetSessionState,
GetSessionState = new GetSessionStateCommand(),
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.SessionState?.State
?? throw CreateMalformed(reply, "GetSessionState");
}
/// <summary>
/// Gets diagnostic information about the worker process serving this session.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The worker process id, version, and MXAccess COM identifiers.</returns>
public async Task<WorkerInfoReply> GetWorkerInfoAsync(CancellationToken cancellationToken = default)
{
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.GetWorkerInfo,
GetWorkerInfo = new GetWorkerInfoCommand(),
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.WorkerInfo
?? throw CreateMalformed(reply, "GetWorkerInfo");
}
/// <summary>
/// Drains events queued in the worker through the diagnostic command path. This removes
/// events from the worker queue; it is not a substitute for <see cref="StreamEventsAsync"/>
/// and is not retried automatically. The reply is bounded by server-side caps, so call
/// repeatedly until it returns an empty list.
/// </summary>
/// <param name="maxEvents">Maximum events to return; 0 lets the server choose.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The drained events, possibly empty.</returns>
public async Task<IReadOnlyList<MxEvent>> DrainEventsAsync(
uint maxEvents = 0,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.DrainEvents,
DrainEvents = new DrainEventsCommand { MaxEvents = maxEvents },
},
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.DrainEvents?.Events.ToArray() ?? [];
}
private static MxGatewayMalformedReplyException CreateMalformed(MxCommandReply reply, string operation)
{
return new MxGatewayMalformedReplyException(
$"{operation} returned a malformed reply: OK reply carried no typed payload",
reply.SessionId,
reply.CorrelationId,
reply.ProtocolStatus,
reply.HasHresult ? reply.Hresult : null,
reply.Statuses.ToArray());
}
/// <summary>
/// Streams events from the worker for this session, optionally starting after a given sequence number.
/// </summary>
@@ -1505,8 +1606,20 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
await CloseAsync().ConfigureAwait(false);
_closeLock.Dispose();
try
{
await CloseAsync().ConfigureAwait(false);
}
catch (Exception ex) when (ex is MxGatewayException or ObjectDisposedException or RpcException)
{
// Best-effort close: the gateway may be unreachable or the owning client
// already disposed. Disposal must not throw or leak the lock; the gateway
// reaps the session on its own idle/detach timers.
}
finally
{
_closeLock.Dispose();
}
}
private Task<MxCommandReply> InvokeCommandAsync(