rename: prefix gateway projects/namespaces with ZB.MOM.WW + sln→slnx

Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.

External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
  MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths

Also fixes two tests that were not rename-related but became visible
while validating the rename:

- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
  gateway service correctly maps to RpcException(Cancelled) per gRPC
  convention was being misclassified as a stream fault. Added a sibling
  catch on RpcException with StatusCode.Cancelled.

- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
  and made it accept either a .git marker OR a .sln/.slnx next to src/
  so the worker-exe walker works in non-git working copies.

clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.

Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
  Tests: 472/472 pass
  Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
  IntegrationTests: 18/18 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 16:22:23 -04:00
parent 867bf18116
commit dc9c0c950c
491 changed files with 32854 additions and 8414 deletions
@@ -0,0 +1,284 @@
using System.Runtime.CompilerServices;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
/// <summary>
/// Concurrency and disposal regression tests for <see cref="GatewaySession"/>.
/// Server-015 and Server-016 audited the split lock discipline between
/// <c>_syncRoot</c> (state transitions) and <c>_closeLock</c> (close serialization)
/// and the un-gated <c>DisposeAsync</c>; these tests pin the post-fix behavior.
/// </summary>
public sealed class GatewaySessionTests
{
/// <summary>
/// Server-015 regression. A <c>TransitionTo(Ready)</c> issued after
/// <see cref="GatewaySession.CloseAsync"/> has set <see cref="SessionState.Closing"/>
/// must not flip the session back to <see cref="SessionState.Ready"/>. The
/// blocking worker shutdown keeps <c>CloseAsync</c> parked between the
/// <c>Closing</c> write and the <c>Closed</c> write, which is precisely the
/// window the audit identified.
/// </summary>
[Fact]
public async Task TransitionTo_AfterCloseStarted_DoesNotOverwriteClosing()
{
BlockingShutdownWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
Task<SessionCloseResult> closeTask = session.CloseAsync("test-close", CancellationToken.None);
await workerClient.WaitForShutdownStartAsync();
// Close has set _state = Closing under _syncRoot and is parked inside
// worker.ShutdownAsync. A concurrent transition (e.g. a late
// SessionWorkerClientFactory lifecycle callback) must not revive the session.
Assert.Equal(SessionState.Closing, session.State);
session.TransitionTo(SessionState.Ready);
Assert.Equal(SessionState.Closing, session.State);
workerClient.ReleaseShutdown();
SessionCloseResult result = await closeTask;
Assert.Equal(SessionState.Closed, result.FinalState);
Assert.Equal(SessionState.Closed, session.State);
await session.DisposeAsync();
}
/// <summary>
/// Server-015 regression. Once <see cref="GatewaySession.CloseAsync"/> finishes,
/// <see cref="GatewaySession.MarkFaulted"/> must not be able to move the
/// session out of <see cref="SessionState.Closed"/> either — the close path's
/// terminal write goes through the same <c>_syncRoot</c> the rest of the state
/// machine uses, so the existing "Closed is terminal" invariant holds.
/// </summary>
[Fact]
public async Task MarkFaulted_AfterCloseCompletes_DoesNotResurrectSession()
{
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
await session.CloseAsync("test-close", CancellationToken.None);
Assert.Equal(SessionState.Closed, session.State);
session.MarkFaulted("late-fault");
Assert.Equal(SessionState.Closed, session.State);
await session.DisposeAsync();
}
/// <summary>
/// Server-028 regression. A <see cref="GatewaySession.MarkFaulted"/> issued
/// while <see cref="GatewaySession.CloseAsync"/> is parked between its
/// <c>Closing</c> and <c>Closed</c> writes must not break the close path's
/// terminal contract: the in-flight close runs to <c>Closed</c>, the fault
/// reason is preserved on <see cref="GatewaySession.FinalFault"/>, and the
/// session does not get stuck in <see cref="SessionState.Faulted"/>. The
/// state machine documents "Closing only allows a transition to Closed or
/// Faulted" — this test pins the resolved end state so a future tightening
/// of <c>MarkFaulted</c> cannot silently regress it.
/// </summary>
[Fact]
public async Task MarkFaulted_DuringInFlightClose_PreservesFaultButYieldsToClose()
{
BlockingShutdownWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
Task<SessionCloseResult> closeTask = session.CloseAsync("test-close", CancellationToken.None);
await workerClient.WaitForShutdownStartAsync();
// Close has set _state = Closing under _syncRoot and is parked inside
// worker.ShutdownAsync. Fault the session from another thread while parked.
Assert.Equal(SessionState.Closing, session.State);
session.MarkFaulted("concurrent-fault");
workerClient.ReleaseShutdown();
SessionCloseResult result = await closeTask;
// Close still wins — Closed is terminal — but the fault reason is preserved
// so observers see the original cause once the session settles.
Assert.Equal(SessionState.Closed, result.FinalState);
Assert.Equal(SessionState.Closed, session.State);
Assert.Equal("concurrent-fault", session.FinalFault);
await session.DisposeAsync();
}
/// <summary>
/// Server-016 regression. <see cref="GatewaySession.DisposeAsync"/> must wait
/// for an in-flight <see cref="GatewaySession.CloseAsync"/> before disposing
/// its semaphore. Without the fix, the close's <c>_closeLock.Release()</c>
/// would race the dispose and raise <see cref="ObjectDisposedException"/>.
/// </summary>
[Fact]
public async Task DisposeAsync_WhileCloseInFlight_WaitsForCloseAndDoesNotThrow()
{
BlockingShutdownWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
Task<SessionCloseResult> closeTask = session.CloseAsync("test-close", CancellationToken.None);
await workerClient.WaitForShutdownStartAsync();
// Start disposing while close is still parked inside worker.ShutdownAsync.
ValueTask disposeTask = session.DisposeAsync();
// Now release the worker shutdown so close can complete.
workerClient.ReleaseShutdown();
// Both must complete cleanly — the close's Release() must run before the
// dispose actually tears the semaphore down.
SessionCloseResult result = await closeTask;
await disposeTask;
Assert.Equal(SessionState.Closed, result.FinalState);
Assert.Equal(1, workerClient.ShutdownCount);
// Worker dispose ran exactly once even with the close/dispose interleave.
Assert.Equal(1, workerClient.DisposeCount);
}
/// <summary>
/// Double-dispose is tolerated: the second call must swallow
/// <see cref="ObjectDisposedException"/> from the already-disposed semaphore
/// rather than propagating it.
/// </summary>
[Fact]
public async Task DisposeAsync_CalledTwice_DoesNotThrow()
{
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
await session.CloseAsync("test-close", CancellationToken.None);
await session.DisposeAsync();
// No second exception — the dispose's defensive ObjectDisposedException catch
// covers the doubled call path that SessionManager.ShutdownAsync could trigger
// if it re-removed a session.
await session.DisposeAsync();
}
private static GatewaySession CreateReadySession(IWorkerClient workerClient)
{
GatewaySession session = new(
sessionId: "session-test",
backendName: "mxaccess",
pipeName: "mxaccess-gateway-1-session-test",
nonce: "nonce",
clientIdentity: "client-1",
clientSessionName: "test-session",
clientCorrelationId: "client-correlation-1",
commandTimeout: TimeSpan.FromSeconds(5),
startupTimeout: TimeSpan.FromSeconds(5),
shutdownTimeout: TimeSpan.FromSeconds(5),
leaseDuration: TimeSpan.FromMinutes(30),
openedAt: DateTimeOffset.UtcNow);
session.AttachWorkerClient(workerClient);
session.MarkReady();
return session;
}
/// <summary>
/// Minimal worker client that parks <see cref="ShutdownAsync"/> until the test
/// explicitly releases it. Used to keep <see cref="GatewaySession.CloseAsync"/>
/// stuck between its <c>Closing</c> and <c>Closed</c> writes so the test can
/// observe and act on the intermediate state.
/// </summary>
private sealed class BlockingShutdownWorkerClient : IWorkerClient
{
private readonly TaskCompletionSource _shutdownStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _shutdownReleased = new(TaskCreationOptions.RunContinuationsAsynchronously);
public string SessionId { get; } = "session-test";
public int? ProcessId { get; } = 1234;
public WorkerClientState State { get; private set; } = WorkerClientState.Ready;
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
public int ShutdownCount { get; private set; }
public int DisposeCount { get; private set; }
public Task WaitForShutdownStartAsync()
{
return _shutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public void ReleaseShutdown()
{
_shutdownReleased.TrySetResult();
}
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
}
public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
ShutdownCount++;
_shutdownStarted.TrySetResult();
await _shutdownReleased.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
State = WorkerClientState.Closed;
}
public void Kill(string reason)
{
State = WorkerClientState.Faulted;
}
public ValueTask DisposeAsync()
{
DisposeCount++;
return ValueTask.CompletedTask;
}
}
private sealed class FakeWorkerClient : IWorkerClient
{
public string SessionId { get; } = "session-test";
public int? ProcessId { get; } = 1234;
public WorkerClientState State { get; } = WorkerClientState.Ready;
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
public int DisposeCount { get; private set; }
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
}
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
public void Kill(string reason)
{
}
public ValueTask DisposeAsync()
{
DisposeCount++;
return ValueTask.CompletedTask;
}
}
}
@@ -0,0 +1,846 @@
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
/// <summary>
/// Tests-013: per-method gateway-side coverage for every
/// <c>GatewaySession.*BulkAsync</c> entry point. Each method gets a
/// round-trip test that pins the <see cref="MxCommandKind"/> sent to the
/// worker, the per-entry payload shape, a failure-mode (per-entry failure
/// surfaced or protocol-status failure) check, and a cancellation-propagation
/// check. The secured-write variants additionally pin that the credential
/// payload (<c>current_user_id</c>, <c>verifier_user_id</c>) is preserved
/// end-to-end and not flattened/redacted by the gateway's command shape.
/// </summary>
public sealed class SessionManagerBulkTests
{
[Fact]
public async Task AddItemBulkAsync_ForwardsOneAddItemBulkCommandAndReturnsResults()
{
FakeBulkWorkerClient workerClient = WithReply(reply => reply.AddItemBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult { ServerHandle = 12, TagAddress = "Galaxy.Tag.Ok", ItemHandle = 511, WasSuccessful = true },
new SubscribeResult { ServerHandle = 12, TagAddress = "Galaxy.Tag.Bad", ItemHandle = 0, WasSuccessful = false, ErrorMessage = "invalid tag" },
},
}, MxCommandKind.AddItemBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<SubscribeResult> results = await session.AddItemBulkAsync(
12,
["Galaxy.Tag.Ok", "Galaxy.Tag.Bad"],
CancellationToken.None);
Assert.Equal(1, workerClient.InvokeCount);
Assert.Equal(MxCommandKind.AddItemBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal(12, workerClient.LastCommand?.Command.AddItemBulk.ServerHandle);
Assert.Equal(["Galaxy.Tag.Ok", "Galaxy.Tag.Bad"], workerClient.LastCommand?.Command.AddItemBulk.TagAddresses);
Assert.Equal(2, results.Count);
Assert.True(results[0].WasSuccessful);
Assert.False(results[1].WasSuccessful);
Assert.Equal("invalid tag", results[1].ErrorMessage);
}
[Fact]
public async Task AddItemBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.AddItemBulkAsync(12, ["Tag.A"], cts.Token));
}
[Fact]
public async Task AdviseItemBulkAsync_ForwardsOneAdviseItemBulkCommandAndReturnsResults()
{
FakeBulkWorkerClient workerClient = WithReply(reply => reply.AdviseItemBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult { ServerHandle = 12, ItemHandle = 901, WasSuccessful = true },
new SubscribeResult { ServerHandle = 12, ItemHandle = 902, WasSuccessful = false, ErrorMessage = "invalid item handle" },
},
}, MxCommandKind.AdviseItemBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<SubscribeResult> results = await session.AdviseItemBulkAsync(
12,
[901, 902],
CancellationToken.None);
Assert.Equal(MxCommandKind.AdviseItemBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal(12, workerClient.LastCommand?.Command.AdviseItemBulk.ServerHandle);
Assert.Equal([901, 902], workerClient.LastCommand?.Command.AdviseItemBulk.ItemHandles);
Assert.Equal(2, results.Count);
Assert.True(results[0].WasSuccessful);
Assert.False(results[1].WasSuccessful);
}
[Fact]
public async Task AdviseItemBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.AdviseItemBulkAsync(12, [101], cts.Token));
}
[Fact]
public async Task RemoveItemBulkAsync_ForwardsOneRemoveItemBulkCommandAndReturnsResults()
{
FakeBulkWorkerClient workerClient = WithReply(reply => reply.RemoveItemBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult { ServerHandle = 12, ItemHandle = 11, WasSuccessful = true },
new SubscribeResult { ServerHandle = 12, ItemHandle = 12, WasSuccessful = false, ErrorMessage = "unknown handle" },
},
}, MxCommandKind.RemoveItemBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<SubscribeResult> results = await session.RemoveItemBulkAsync(
12,
[11, 12],
CancellationToken.None);
Assert.Equal(MxCommandKind.RemoveItemBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal([11, 12], workerClient.LastCommand?.Command.RemoveItemBulk.ItemHandles);
Assert.Equal(2, results.Count);
Assert.False(results[1].WasSuccessful);
}
[Fact]
public async Task RemoveItemBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.RemoveItemBulkAsync(12, [11], cts.Token));
}
[Fact]
public async Task UnAdviseItemBulkAsync_ForwardsOneUnAdviseItemBulkCommandAndReturnsResults()
{
FakeBulkWorkerClient workerClient = WithReply(reply => reply.UnAdviseItemBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult { ServerHandle = 12, ItemHandle = 21, WasSuccessful = true },
new SubscribeResult { ServerHandle = 12, ItemHandle = 22, WasSuccessful = false, ErrorMessage = "not advised" },
},
}, MxCommandKind.UnAdviseItemBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<SubscribeResult> results = await session.UnAdviseItemBulkAsync(
12,
[21, 22],
CancellationToken.None);
Assert.Equal(MxCommandKind.UnAdviseItemBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal([21, 22], workerClient.LastCommand?.Command.UnAdviseItemBulk.ItemHandles);
Assert.Equal(2, results.Count);
Assert.False(results[1].WasSuccessful);
Assert.Equal("not advised", results[1].ErrorMessage);
}
[Fact]
public async Task UnAdviseItemBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.UnAdviseItemBulkAsync(12, [21], cts.Token));
}
[Fact]
public async Task SubscribeBulkAsync_SurfacesPerEntryFailures()
{
// SubscribeBulkAsync already has a happy-path test in SessionManagerTests
// (GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults);
// this complementary test pins the per-entry failure-surface behaviour.
FakeBulkWorkerClient workerClient = WithReply(reply => reply.SubscribeBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult { ServerHandle = 12, TagAddress = "Galaxy.Good", ItemHandle = 501, WasSuccessful = true },
new SubscribeResult { ServerHandle = 12, TagAddress = "Galaxy.Bad", ItemHandle = 0, WasSuccessful = false, ErrorMessage = "MXAccess subscribe failed" },
},
}, MxCommandKind.SubscribeBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<SubscribeResult> results = await session.SubscribeBulkAsync(
12,
["Galaxy.Good", "Galaxy.Bad"],
CancellationToken.None);
Assert.Equal(2, results.Count);
Assert.True(results[0].WasSuccessful);
Assert.False(results[1].WasSuccessful);
Assert.Equal("MXAccess subscribe failed", results[1].ErrorMessage);
}
[Fact]
public async Task SubscribeBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.SubscribeBulkAsync(12, ["Tag"], cts.Token));
}
[Fact]
public async Task UnsubscribeBulkAsync_ForwardsOneUnsubscribeBulkCommandAndReturnsResults()
{
FakeBulkWorkerClient workerClient = WithReply(reply => reply.UnsubscribeBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult { ServerHandle = 12, ItemHandle = 31, WasSuccessful = true },
new SubscribeResult { ServerHandle = 12, ItemHandle = 32, WasSuccessful = false, ErrorMessage = "unknown handle" },
},
}, MxCommandKind.UnsubscribeBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<SubscribeResult> results = await session.UnsubscribeBulkAsync(
12,
[31, 32],
CancellationToken.None);
Assert.Equal(MxCommandKind.UnsubscribeBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal([31, 32], workerClient.LastCommand?.Command.UnsubscribeBulk.ItemHandles);
Assert.Equal(2, results.Count);
Assert.False(results[1].WasSuccessful);
}
[Fact]
public async Task UnsubscribeBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.UnsubscribeBulkAsync(12, [31], cts.Token));
}
[Fact]
public async Task WriteBulkAsync_SurfacesPerEntryFailures()
{
// Complement the existing happy-path WriteBulk test in SessionManagerTests
// with an explicit per-entry failure assertion plus payload-shape pinning.
FakeBulkWorkerClient workerClient = WithReply(reply => reply.WriteBulk = new BulkWriteReply
{
Results =
{
new BulkWriteResult { ServerHandle = 12, ItemHandle = 901, WasSuccessful = true },
new BulkWriteResult { ServerHandle = 12, ItemHandle = 902, WasSuccessful = false, ErrorMessage = "MXAccess invalid handle" },
},
}, MxCommandKind.WriteBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<BulkWriteResult> results = await session.WriteBulkAsync(
12,
new[]
{
new WriteBulkEntry { ItemHandle = 901, UserId = 5, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 11 } },
new WriteBulkEntry { ItemHandle = 902, UserId = 5, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 22 } },
},
CancellationToken.None);
Assert.Equal(MxCommandKind.WriteBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal(2, workerClient.LastCommand?.Command.WriteBulk.Entries.Count);
Assert.Equal(901, workerClient.LastCommand?.Command.WriteBulk.Entries[0].ItemHandle);
Assert.Equal(11, workerClient.LastCommand?.Command.WriteBulk.Entries[0].Value.Int32Value);
Assert.False(results[1].WasSuccessful);
Assert.Equal("MXAccess invalid handle", results[1].ErrorMessage);
}
[Fact]
public async Task WriteBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.WriteBulkAsync(
12,
new[] { new WriteBulkEntry { ItemHandle = 1, UserId = 1, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 } } },
cts.Token));
}
[Fact]
public async Task Write2BulkAsync_ForwardsOneWrite2BulkCommandAndPreservesTimestampPayload()
{
FakeBulkWorkerClient workerClient = WithReply(reply => reply.Write2Bulk = new BulkWriteReply
{
Results =
{
new BulkWriteResult { ServerHandle = 12, ItemHandle = 701, WasSuccessful = true },
new BulkWriteResult { ServerHandle = 12, ItemHandle = 702, WasSuccessful = false, ErrorMessage = "MXAccess Write2 failed" },
},
}, MxCommandKind.Write2Bulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<BulkWriteResult> results = await session.Write2BulkAsync(
12,
new[]
{
new Write2BulkEntry
{
ItemHandle = 701,
UserId = 5,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 11 },
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 1234567890L },
},
new Write2BulkEntry
{
ItemHandle = 702,
UserId = 5,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 22 },
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 1234567891L },
},
},
CancellationToken.None);
Assert.Equal(MxCommandKind.Write2Bulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal(12, workerClient.LastCommand?.Command.Write2Bulk.ServerHandle);
Assert.Equal(2, workerClient.LastCommand?.Command.Write2Bulk.Entries.Count);
Assert.Equal(701, workerClient.LastCommand?.Command.Write2Bulk.Entries[0].ItemHandle);
Assert.Equal(1234567890L, workerClient.LastCommand?.Command.Write2Bulk.Entries[0].TimestampValue.Int64Value);
Assert.False(results[1].WasSuccessful);
}
[Fact]
public async Task Write2BulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.Write2BulkAsync(
12,
new[]
{
new Write2BulkEntry
{
ItemHandle = 1,
UserId = 1,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 },
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 0L },
},
},
cts.Token));
}
[Fact]
public async Task WriteSecuredBulkAsync_ForwardsOneWriteSecuredBulkCommandAndPreservesCredentialPayload()
{
// The secured variants carry caller credential identifiers (CurrentUserId /
// VerifierUserId). Pin that those survive the gateway round-trip end-to-end —
// the over-the-wire command shape must NOT redact or flatten them, only the
// *log surface* (see GatewaySession's redaction rules) is allowed to drop them.
FakeBulkWorkerClient workerClient = WithReply(reply => reply.WriteSecuredBulk = new BulkWriteReply
{
Results =
{
new BulkWriteResult { ServerHandle = 12, ItemHandle = 601, WasSuccessful = true },
new BulkWriteResult { ServerHandle = 12, ItemHandle = 602, WasSuccessful = false, ErrorMessage = "MXAccess secured-write rejected" },
},
}, MxCommandKind.WriteSecuredBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<BulkWriteResult> results = await session.WriteSecuredBulkAsync(
12,
new[]
{
new WriteSecuredBulkEntry
{
ItemHandle = 601,
CurrentUserId = 7,
VerifierUserId = 8,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 1 },
},
new WriteSecuredBulkEntry
{
ItemHandle = 602,
CurrentUserId = 7,
VerifierUserId = 8,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 2 },
},
},
CancellationToken.None);
Assert.Equal(MxCommandKind.WriteSecuredBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal(12, workerClient.LastCommand?.Command.WriteSecuredBulk.ServerHandle);
Assert.Equal(2, workerClient.LastCommand?.Command.WriteSecuredBulk.Entries.Count);
WriteSecuredBulkEntry firstEntry = workerClient.LastCommand!.Command.WriteSecuredBulk.Entries[0];
Assert.Equal(601, firstEntry.ItemHandle);
Assert.Equal(7, firstEntry.CurrentUserId);
Assert.Equal(8, firstEntry.VerifierUserId);
Assert.Equal(1, firstEntry.Value.Int32Value);
Assert.False(results[1].WasSuccessful);
Assert.Equal("MXAccess secured-write rejected", results[1].ErrorMessage);
}
[Fact]
public async Task WriteSecuredBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.WriteSecuredBulkAsync(
12,
new[]
{
new WriteSecuredBulkEntry
{
ItemHandle = 1,
CurrentUserId = 7,
VerifierUserId = 8,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 },
},
},
cts.Token));
}
/// <summary>
/// Tests-022: Pin mid-flight cancellation behaviour for at least one bulk
/// path. Unlike the pre-cancel <c>WriteSecuredBulkAsync_PropagatesCancellation</c>
/// above, this fake's <see cref="MidFlightBulkWorkerClient.InvokeAsync"/>
/// returns a <see cref="TaskCompletionSource"/>-backed task that does NOT
/// complete until the registered token fires. The session call therefore
/// reaches <c>InvokeBulkInternalAsync</c> → <c>InvokeAsync</c> →
/// <c>workerClient.InvokeAsync</c> and parks on an in-flight await; only
/// after that does <c>cts.CancelAsync()</c> fire. This is the path a real
/// client closing its stream would hit, which the pre-cancel pattern can't
/// exercise.
/// </summary>
[Fact]
public async Task WriteSecuredBulkAsync_WhenCancelledMidFlight_ThrowsOperationCanceledForRequestToken()
{
MidFlightBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
Task<IReadOnlyList<BulkWriteResult>> writeTask = session.WriteSecuredBulkAsync(
12,
new[]
{
new WriteSecuredBulkEntry
{
ItemHandle = 1,
CurrentUserId = 7,
VerifierUserId = 8,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 },
},
},
cts.Token);
// Wait until the gateway has descended into the worker's InvokeAsync and
// registered its cancellation continuation — only then is this a true
// mid-flight cancel.
await workerClient.InvokeStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.False(writeTask.IsCompleted);
await cts.CancelAsync();
OperationCanceledException exception = await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await writeTask);
Assert.Equal(cts.Token, exception.CancellationToken);
Assert.Equal(1, workerClient.InvokeCount);
}
[Fact]
public async Task WriteSecured2BulkAsync_ForwardsOneWriteSecured2BulkCommandAndPreservesCredentialAndTimestampPayload()
{
FakeBulkWorkerClient workerClient = WithReply(reply => reply.WriteSecured2Bulk = new BulkWriteReply
{
Results =
{
new BulkWriteResult { ServerHandle = 12, ItemHandle = 801, WasSuccessful = true },
new BulkWriteResult { ServerHandle = 12, ItemHandle = 802, WasSuccessful = false, ErrorMessage = "MXAccess secured2-write rejected" },
},
}, MxCommandKind.WriteSecured2Bulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<BulkWriteResult> results = await session.WriteSecured2BulkAsync(
12,
new[]
{
new WriteSecured2BulkEntry
{
ItemHandle = 801,
CurrentUserId = 7,
VerifierUserId = 8,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 1 },
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 1700000000L },
},
new WriteSecured2BulkEntry
{
ItemHandle = 802,
CurrentUserId = 7,
VerifierUserId = 8,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 2 },
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 1700000001L },
},
},
CancellationToken.None);
Assert.Equal(MxCommandKind.WriteSecured2Bulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal(2, workerClient.LastCommand?.Command.WriteSecured2Bulk.Entries.Count);
WriteSecured2BulkEntry firstEntry = workerClient.LastCommand!.Command.WriteSecured2Bulk.Entries[0];
Assert.Equal(801, firstEntry.ItemHandle);
Assert.Equal(7, firstEntry.CurrentUserId);
Assert.Equal(8, firstEntry.VerifierUserId);
Assert.Equal(1, firstEntry.Value.Int32Value);
Assert.Equal(1700000000L, firstEntry.TimestampValue.Int64Value);
Assert.False(results[1].WasSuccessful);
}
[Fact]
public async Task WriteSecured2BulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.WriteSecured2BulkAsync(
12,
new[]
{
new WriteSecured2BulkEntry
{
ItemHandle = 1,
CurrentUserId = 7,
VerifierUserId = 8,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 },
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 0L },
},
},
cts.Token));
}
[Fact]
public async Task ReadBulkAsync_SurfacesPerEntryFailures()
{
// Complement the existing happy-path ReadBulk test in SessionManagerTests
// with the failure-mode case where one tag failed to read.
FakeBulkWorkerClient workerClient = WithReply(reply => reply.ReadBulk = new BulkReadReply
{
Results =
{
new BulkReadResult
{
ServerHandle = 12,
TagAddress = "Galaxy.Good",
ItemHandle = 511,
WasSuccessful = true,
WasCached = false,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 42 },
},
new BulkReadResult
{
ServerHandle = 12,
TagAddress = "Galaxy.Bad",
ItemHandle = 0,
WasSuccessful = false,
ErrorMessage = "MXAccess read timed out",
},
},
}, MxCommandKind.ReadBulk);
GatewaySession session = await OpenSessionAsync(workerClient);
IReadOnlyList<BulkReadResult> results = await session.ReadBulkAsync(
12,
["Galaxy.Good", "Galaxy.Bad"],
TimeSpan.FromMilliseconds(750),
CancellationToken.None);
Assert.Equal(MxCommandKind.ReadBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal(750u, workerClient.LastCommand?.Command.ReadBulk.TimeoutMs);
Assert.Equal(["Galaxy.Good", "Galaxy.Bad"], workerClient.LastCommand?.Command.ReadBulk.TagAddresses);
Assert.Equal(2, results.Count);
Assert.True(results[0].WasSuccessful);
Assert.False(results[1].WasSuccessful);
Assert.Equal("MXAccess read timed out", results[1].ErrorMessage);
}
[Fact]
public async Task ReadBulkAsync_PropagatesCancellation()
{
FakeBulkWorkerClient workerClient = new();
GatewaySession session = await OpenSessionAsync(workerClient);
using CancellationTokenSource cts = new();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await session.ReadBulkAsync(
12,
["Galaxy.Tag"],
TimeSpan.FromMilliseconds(500),
cts.Token));
}
// -----------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------
private static FakeBulkWorkerClient WithReply(Action<MxCommandReply> populate, MxCommandKind kind)
{
MxCommandReply reply = new()
{
SessionId = "session-1",
CorrelationId = "correlation-1",
Kind = kind,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
};
populate(reply);
return new FakeBulkWorkerClient
{
InvokeReply = new WorkerCommandReply { Reply = reply },
};
}
private static async Task<GatewaySession> OpenSessionAsync(FakeBulkWorkerClient workerClient)
{
return await OpenSessionAsync((IWorkerClient)workerClient);
}
private static async Task<GatewaySession> OpenSessionAsync(IWorkerClient workerClient)
{
SessionManager manager = CreateManager(workerClient);
return await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
}
private static SessionManager CreateManager(IWorkerClient workerClient)
{
return new SessionManager(
new SessionRegistry(),
new FakeBulkSessionWorkerClientFactory(workerClient),
Options.Create(new GatewayOptions
{
Sessions = new SessionOptions
{
DefaultCommandTimeoutSeconds = 30,
MaxSessions = 16,
DefaultLeaseSeconds = 1800,
},
Worker = new WorkerOptions
{
StartupTimeoutSeconds = 30,
ShutdownTimeoutSeconds = 10,
},
}),
new GatewayMetrics());
}
private static SessionOpenRequest CreateOpenRequest()
{
return new SessionOpenRequest(
RequestedBackend: null,
ClientSessionName: "test-session",
ClientCorrelationId: "client-correlation-1",
CommandTimeout: Duration.FromTimeSpan(TimeSpan.FromSeconds(5)));
}
private sealed class FakeBulkSessionWorkerClientFactory(IWorkerClient workerClient) : ISessionWorkerClientFactory
{
/// <inheritdoc />
public Task<IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken)
{
return Task.FromResult(workerClient);
}
}
private sealed class FakeBulkWorkerClient : IWorkerClient
{
/// <inheritdoc />
public string SessionId { get; init; } = "session-1";
/// <inheritdoc />
public int? ProcessId { get; init; } = 1234;
/// <inheritdoc />
public WorkerClientState State { get; set; } = WorkerClientState.Ready;
/// <inheritdoc />
public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>Gets the number of times Invoke was called on the fake worker client.</summary>
public int InvokeCount { get; private set; }
/// <summary>Gets the last command invoked on the fake worker client.</summary>
public WorkerCommand? LastCommand { get; private set; }
/// <summary>Gets the reply to return for invoke calls on the fake worker client.</summary>
public WorkerCommandReply? InvokeReply { get; init; }
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
InvokeCount++;
LastCommand = command;
if (InvokeReply is not null)
{
return Task.FromResult(InvokeReply);
}
MxCommandKind kind = command.Command?.Kind ?? MxCommandKind.Unspecified;
return Task.FromResult(new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = SessionId,
CorrelationId = "correlation-1",
Kind = kind,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
},
});
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
/// <inheritdoc />
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
State = WorkerClientState.Closed;
return Task.CompletedTask;
}
/// <inheritdoc />
public void Kill(string reason) => State = WorkerClientState.Faulted;
/// <inheritdoc />
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>
/// Mid-flight cancellation fake for Tests-022.
/// <see cref="InvokeAsync"/> signals <see cref="InvokeStarted"/>, registers
/// a cancellation continuation on the caller's <see cref="CancellationToken"/>,
/// and parks on a <see cref="TaskCompletionSource{TResult}"/> that completes
/// only when the token fires or the fake is shut down. This is the only
/// way to land an <see cref="OperationCanceledException"/> on the async
/// continuation rather than the synchronous fast-path inside
/// <c>ThrowIfCancellationRequested</c>.
/// </summary>
private sealed class MidFlightBulkWorkerClient : IWorkerClient
{
private readonly TaskCompletionSource<WorkerCommandReply> _invokeCompletion =
new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <inheritdoc />
public string SessionId { get; init; } = "session-1";
/// <inheritdoc />
public int? ProcessId { get; init; } = 1234;
/// <inheritdoc />
public WorkerClientState State { get; set; } = WorkerClientState.Ready;
/// <inheritdoc />
public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>Gets the number of times <see cref="InvokeAsync"/> was entered.</summary>
public int InvokeCount { get; private set; }
/// <summary>Signals when <see cref="InvokeAsync"/> first enters — the test
/// awaits this before triggering mid-flight cancellation.</summary>
public TaskCompletionSource InvokeStarted { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken)
{
InvokeCount++;
// Register cancellation BEFORE signalling start so the test can be
// certain the continuation is wired the moment InvokeStarted resolves.
cancellationToken.Register(() => _invokeCompletion.TrySetCanceled(cancellationToken));
InvokeStarted.TrySetResult();
return _invokeCompletion.Task;
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
/// <inheritdoc />
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
State = WorkerClientState.Closed;
_invokeCompletion.TrySetCanceled(cancellationToken);
return Task.CompletedTask;
}
/// <inheritdoc />
public void Kill(string reason)
{
State = WorkerClientState.Faulted;
_invokeCompletion.TrySetCanceled();
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
_invokeCompletion.TrySetCanceled();
return ValueTask.CompletedTask;
}
}
}
@@ -0,0 +1,797 @@
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
public sealed class SessionManagerTests
{
/// <summary>Verifies that opening a session with a ready worker registers the session in ready state.</summary>
[Fact]
public async Task OpenSessionAsync_WithWorkerReady_RegistersReadySession()
{
FakeWorkerClient workerClient = new();
FakeSessionWorkerClientFactory factory = new(workerClient)
{
ApplyLifecycleTransitions = true,
};
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(factory, metrics: metrics);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
Assert.True(manager.TryGetSession(session.SessionId, out GatewaySession? registered));
Assert.Same(session, registered);
Assert.Equal(SessionState.Ready, session.State);
Assert.Equal("client-1", session.ClientIdentity);
Assert.Equal(["StartingWorker", "WaitingForPipe", "Handshaking", "InitializingWorker"], factory.ObservedStates);
Assert.Equal(1, metrics.GetSnapshot().OpenSessions);
Assert.Equal(1, metrics.GetSnapshot().SessionsOpened);
}
/// <summary>Verifies that opening a session sets the initial lease expiry from the configured default lease.</summary>
[Fact]
public async Task OpenSessionAsync_SetsInitialDefaultLease()
{
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-29T10:00:00Z", System.Globalization.CultureInfo.InvariantCulture));
GatewayOptions options = CreateOptions(defaultLeaseSeconds: 1800);
SessionManager manager = CreateManager(
new FakeSessionWorkerClientFactory(new FakeWorkerClient()),
options: options,
timeProvider: clock);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
Assert.Equal(clock.GetUtcNow() + TimeSpan.FromMinutes(30), session.LeaseExpiresAt);
}
[Fact]
public async Task OpenSessionAsync_GeneratesClientCorrelationIdFromClientNameAndSessionId()
{
SessionOpenRequest request = CreateOpenRequest() with
{
ClientSessionName = "rust-load-client",
ClientCorrelationId = "caller-provided-correlation",
};
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(new FakeWorkerClient()));
GatewaySession session = await manager.OpenSessionAsync(request, "client-1", CancellationToken.None);
Assert.Equal($"rust-load-client-{session.SessionId}", session.ClientCorrelationId);
}
/// <summary>Verifies that opening a session without a client session name uses the client correlation prefix.</summary>
[Fact]
public async Task OpenSessionAsync_WhenClientSessionNameMissing_UsesClientCorrelationPrefix()
{
SessionOpenRequest request = CreateOpenRequest() with
{
ClientSessionName = "",
};
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(new FakeWorkerClient()));
GatewaySession session = await manager.OpenSessionAsync(request, "client-1", CancellationToken.None);
Assert.Equal($"client-{session.SessionId}", session.ClientCorrelationId);
}
/// <summary>Verifies that invoking a command on a ready session forwards the command to the worker.</summary>
[Fact]
public async Task InvokeAsync_WhenSessionReady_ForwardsCommandToWorker()
{
FakeWorkerClient workerClient = new();
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient));
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
WorkerCommandReply reply = await manager.InvokeAsync(
session.SessionId,
CreateCommand(MxCommandKind.Ping),
CancellationToken.None);
Assert.Equal(1, workerClient.InvokeCount);
Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind);
}
/// <summary>Verifies that invoking a command on a ready session refreshes its lease expiry.</summary>
[Fact]
public async Task InvokeAsync_WhenSessionReady_RefreshesLease()
{
GatewaySession session = new(
"session-lease-refresh",
"mxaccess",
"mxaccess-gateway-1-session-lease-refresh",
"nonce",
"client-1",
"test-session",
"client-correlation-1",
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(5),
TimeSpan.FromMinutes(30),
DateTimeOffset.UtcNow - TimeSpan.FromHours(1));
session.AttachWorkerClient(new FakeWorkerClient());
session.MarkReady();
DateTimeOffset? initialLease = session.LeaseExpiresAt;
await session.InvokeAsync(CreateCommand(MxCommandKind.Ping), CancellationToken.None);
Assert.True(session.LeaseExpiresAt > initialLease);
Assert.True(session.LeaseExpiresAt > DateTimeOffset.UtcNow);
}
[Fact]
public async Task GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
{
FakeWorkerClient workerClient = new()
{
InvokeReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = "session-1",
CorrelationId = "correlation-1",
Kind = MxCommandKind.SubscribeBulk,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
SubscribeBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult
{
ServerHandle = 12,
TagAddress = "Galaxy.Tag.Value",
ItemHandle = 512,
WasSuccessful = true,
},
},
},
},
},
};
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient));
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
IReadOnlyList<SubscribeResult> results = await session.SubscribeBulkAsync(
12,
["Galaxy.Tag.Value"],
CancellationToken.None);
SubscribeResult result = Assert.Single(results);
Assert.Equal(512, result.ItemHandle);
Assert.Equal(1, workerClient.InvokeCount);
Assert.Equal(MxCommandKind.SubscribeBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal(["Galaxy.Tag.Value"], workerClient.LastCommand?.Command.SubscribeBulk.TagAddresses);
}
[Fact]
public async Task GatewaySessionWriteBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
{
FakeWorkerClient workerClient = new()
{
InvokeReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = "session-1",
CorrelationId = "correlation-1",
Kind = MxCommandKind.WriteBulk,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
WriteBulk = new BulkWriteReply
{
Results =
{
new BulkWriteResult
{
ServerHandle = 12,
ItemHandle = 901,
WasSuccessful = true,
},
new BulkWriteResult
{
ServerHandle = 12,
ItemHandle = 902,
WasSuccessful = false,
ErrorMessage = "MXAccess invalid handle",
},
},
},
},
},
};
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient));
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
IReadOnlyList<BulkWriteResult> results = await session.WriteBulkAsync(
12,
new[]
{
new WriteBulkEntry
{
ItemHandle = 901,
UserId = 5,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 11 },
},
new WriteBulkEntry
{
ItemHandle = 902,
UserId = 5,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 22 },
},
},
CancellationToken.None);
Assert.Equal(2, results.Count);
Assert.True(results[0].WasSuccessful);
Assert.False(results[1].WasSuccessful);
Assert.Equal(MxCommandKind.WriteBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal(2, workerClient.LastCommand?.Command.WriteBulk.Entries.Count);
}
[Fact]
public async Task GatewaySessionReadBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
{
FakeWorkerClient workerClient = new()
{
InvokeReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = "session-1",
CorrelationId = "correlation-1",
Kind = MxCommandKind.ReadBulk,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
ReadBulk = new BulkReadReply
{
Results =
{
new BulkReadResult
{
ServerHandle = 12,
TagAddress = "Galaxy.Tag.Value",
ItemHandle = 512,
WasSuccessful = true,
WasCached = true,
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 42 },
},
},
},
},
},
};
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient));
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
IReadOnlyList<BulkReadResult> results = await session.ReadBulkAsync(
12,
["Galaxy.Tag.Value"],
TimeSpan.FromMilliseconds(500),
CancellationToken.None);
BulkReadResult result = Assert.Single(results);
Assert.True(result.WasSuccessful);
Assert.True(result.WasCached);
Assert.Equal(42, result.Value.Int32Value);
Assert.Equal(MxCommandKind.ReadBulk, workerClient.LastCommand?.Command.Kind);
Assert.Equal(["Galaxy.Tag.Value"], workerClient.LastCommand?.Command.ReadBulk.TagAddresses);
Assert.Equal(500u, workerClient.LastCommand?.Command.ReadBulk.TimeoutMs);
}
/// <summary>Verifies that invoking a command on a faulted session rejects the command.</summary>
[Fact]
public async Task InvokeAsync_WhenSessionFaulted_RejectsCommand()
{
FakeWorkerClient workerClient = new();
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient));
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
session.MarkFaulted("test fault");
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
async () => await manager.InvokeAsync(
session.SessionId,
CreateCommand(MxCommandKind.Ping),
CancellationToken.None));
Assert.Equal(SessionManagerErrorCode.SessionNotReady, exception.ErrorCode);
Assert.Equal(0, workerClient.InvokeCount);
}
/// <summary>
/// Server-030 regression: when the gateway-side <c>SessionState</c> is
/// <c>Ready</c> but the worker client's own state is not, the diagnostic
/// must surface both states so the mismatch is actionable instead of
/// producing a self-contradictory "Session ... is not ready. Current
/// state is Ready." message.
/// </summary>
[Fact]
public async Task InvokeAsync_WhenWorkerNotReadyButSessionReady_DiagnosticIncludesBothStates()
{
FakeWorkerClient workerClient = new();
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient));
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
// Force a state mismatch: session stays Ready, worker transitions out.
workerClient.State = WorkerClientState.Handshaking;
Assert.Equal(SessionState.Ready, session.State);
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
async () => await manager.InvokeAsync(
session.SessionId,
CreateCommand(MxCommandKind.Ping),
CancellationToken.None));
Assert.Equal(SessionManagerErrorCode.SessionNotReady, exception.ErrorCode);
Assert.Contains("Session state is Ready", exception.Message);
Assert.Contains("worker state is Handshaking", exception.Message);
Assert.Equal(0, workerClient.InvokeCount);
}
/// <summary>Verifies that closing a session removes it from the registry.</summary>
[Fact]
public async Task CloseSessionAsync_RemovesClosedSession()
{
FakeWorkerClient workerClient = new();
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient), metrics: metrics);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
SessionCloseResult firstClose = await manager.CloseSessionAsync(session.SessionId, CancellationToken.None);
SessionManagerException secondClose = await Assert.ThrowsAsync<SessionManagerException>(
async () => await manager.CloseSessionAsync(session.SessionId, CancellationToken.None));
Assert.False(firstClose.AlreadyClosed);
Assert.Equal(SessionState.Closed, firstClose.FinalState);
Assert.Equal(SessionManagerErrorCode.SessionNotFound, secondClose.ErrorCode);
Assert.Equal(1, workerClient.ShutdownCount);
Assert.Equal(1, metrics.GetSnapshot().SessionsClosed);
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
}
/// <summary>Verifies that closing a session kills the worker when shutdown fails.</summary>
[Fact]
public async Task CloseSessionAsync_WhenWorkerShutdownFails_KillsWorker()
{
FakeWorkerClient workerClient = new()
{
ShutdownException = new WorkerClientException(
WorkerClientErrorCode.ShutdownTimeout,
"Worker shutdown timed out."),
};
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient));
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
async () => await manager.CloseSessionAsync(session.SessionId, CancellationToken.None));
Assert.Equal(SessionManagerErrorCode.CloseFailed, exception.ErrorCode);
Assert.Equal(1, workerClient.ShutdownCount);
Assert.Equal(1, workerClient.KillCount);
}
/// <summary>Verifies that when worker shutdown fails, the session is removed and the slot is released.</summary>
[Fact]
public async Task CloseSessionAsync_WhenWorkerShutdownFails_RemovesSessionAndReleasesSlot()
{
FakeWorkerClient failingWorkerClient = new()
{
ShutdownException = new WorkerClientException(
WorkerClientErrorCode.ShutdownTimeout,
"Worker shutdown timed out."),
};
FakeWorkerClient replacementWorkerClient = new();
SessionRegistry registry = new();
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(
new QueueingSessionWorkerClientFactory(failingWorkerClient, replacementWorkerClient),
registry,
metrics,
CreateOptions(maxSessions: 1));
GatewaySession firstSession = await manager.OpenSessionAsync(
CreateOpenRequest(),
"client-1",
CancellationToken.None);
metrics.EventReceived(firstSession.SessionId, MxEventFamily.OnDataChange.ToString());
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
async () => await manager.CloseSessionAsync(firstSession.SessionId, CancellationToken.None));
GatewaySession secondSession = await manager.OpenSessionAsync(
CreateOpenRequest(),
"client-2",
CancellationToken.None);
Assert.Equal(SessionManagerErrorCode.CloseFailed, exception.ErrorCode);
Assert.False(manager.TryGetSession(firstSession.SessionId, out _));
Assert.True(manager.TryGetSession(secondSession.SessionId, out _));
Assert.Equal(1, registry.Count);
Assert.Equal(1, failingWorkerClient.KillCount);
Assert.Equal(1, failingWorkerClient.DisposeCount);
GatewayMetricsSnapshot snapshot = metrics.GetSnapshot();
Assert.Equal(0, snapshot.SessionsClosed);
Assert.False(snapshot.EventsBySession.ContainsKey(firstSession.SessionId));
Assert.Equal(1, snapshot.OpenSessions);
}
/// <summary>Verifies that when the second close is canceled, the session is not removed if owned by the first close.</summary>
[Fact]
public async Task CloseSessionAsync_WhenSecondCloseIsCanceled_DoesNotRemoveSessionOwnedByFirstClose()
{
FakeWorkerClient workerClient = new()
{
BlockShutdown = true,
};
SessionRegistry registry = new();
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(
new FakeSessionWorkerClientFactory(workerClient),
registry,
metrics,
CreateOptions(maxSessions: 1));
GatewaySession session = await manager.OpenSessionAsync(
CreateOpenRequest(),
"client-1",
CancellationToken.None);
Task<SessionCloseResult> firstClose = manager.CloseSessionAsync(session.SessionId, CancellationToken.None);
await workerClient.WaitForShutdownStartAsync();
using CancellationTokenSource secondCloseCancellation = new();
Task<SessionCloseResult> secondClose = manager.CloseSessionAsync(
session.SessionId,
secondCloseCancellation.Token);
await secondCloseCancellation.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
async () => await secondClose);
Assert.True(manager.TryGetSession(session.SessionId, out _));
Assert.Equal(1, registry.Count);
Assert.Equal(0, workerClient.DisposeCount);
Assert.Equal(0, metrics.GetSnapshot().SessionsClosed);
Assert.Equal(1, metrics.GetSnapshot().OpenSessions);
workerClient.ReleaseShutdown();
SessionCloseResult closeResult = await firstClose;
Assert.Equal(SessionState.Closed, closeResult.FinalState);
Assert.False(manager.TryGetSession(session.SessionId, out _));
Assert.Equal(0, registry.Count);
Assert.Equal(1, workerClient.DisposeCount);
Assert.Equal(1, metrics.GetSnapshot().SessionsClosed);
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
}
/// <summary>Verifies that when worker creation fails, the session is removed from the registry.</summary>
[Fact]
public async Task OpenSessionAsync_WhenWorkerCreationFails_RemovesSessionFromRegistry()
{
SessionRegistry registry = new();
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(
new FailingSessionWorkerClientFactory(),
registry,
metrics);
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
async () => await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None));
Assert.Equal(SessionManagerErrorCode.OpenFailed, exception.ErrorCode);
Assert.Equal(0, registry.Count);
Assert.Equal(0, metrics.GetSnapshot().SessionsOpened);
Assert.Equal(1, metrics.GetSnapshot().Faults);
}
/// <summary>Verifies that closing expired leases only closes expired sessions.</summary>
[Fact]
public async Task CloseExpiredLeasesAsync_ClosesExpiredSessionsOnly()
{
FakeWorkerClient expiredClient = new();
FakeWorkerClient activeClient = new();
QueueingSessionWorkerClientFactory factory = new(expiredClient, activeClient);
SessionManager manager = CreateManager(factory);
GatewaySession expiredSession = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
GatewaySession activeSession = await manager.OpenSessionAsync(CreateOpenRequest(), "client-2", CancellationToken.None);
DateTimeOffset now = DateTimeOffset.UtcNow;
expiredSession.ExtendLease(now.AddSeconds(-1));
activeSession.ExtendLease(now.AddMinutes(5));
int closedCount = await manager.CloseExpiredLeasesAsync(now, CancellationToken.None);
Assert.Equal(1, closedCount);
Assert.Equal(SessionState.Closed, expiredSession.State);
Assert.Equal(SessionState.Ready, activeSession.State);
Assert.Equal(1, expiredClient.ShutdownCount);
Assert.Equal(0, activeClient.ShutdownCount);
}
/// <summary>Verifies that an expired-lease sweep leaves a session with an active event subscriber open.</summary>
[Fact]
public async Task CloseExpiredLeasesAsync_DoesNotCloseActiveEventSubscriber()
{
FakeWorkerClient workerClient = new();
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient));
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
DateTimeOffset now = DateTimeOffset.UtcNow;
session.ExtendLease(now.AddSeconds(-1));
using IDisposable eventSubscriber = session.AttachEventSubscriber(allowMultipleSubscribers: false);
int closedCount = await manager.CloseExpiredLeasesAsync(now, CancellationToken.None);
Assert.Equal(0, closedCount);
Assert.Equal(SessionState.Ready, session.State);
Assert.Equal(0, workerClient.ShutdownCount);
}
[Fact]
public async Task ShutdownAsync_ClosesAllRegisteredSessions()
{
FakeWorkerClient firstClient = new();
FakeWorkerClient secondClient = new();
QueueingSessionWorkerClientFactory factory = new(firstClient, secondClient);
using GatewayMetrics metrics = new();
SessionManager manager = CreateManager(factory, metrics: metrics);
GatewaySession firstSession = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
GatewaySession secondSession = await manager.OpenSessionAsync(CreateOpenRequest(), "client-2", CancellationToken.None);
await manager.ShutdownAsync(CancellationToken.None);
Assert.Equal(SessionState.Closed, firstSession.State);
Assert.Equal(SessionState.Closed, secondSession.State);
Assert.Equal(1, firstClient.ShutdownCount);
Assert.Equal(1, secondClient.ShutdownCount);
Assert.Equal(2, metrics.GetSnapshot().SessionsClosed);
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
}
/// <summary>Creates a session manager for testing.</summary>
/// <param name="factory">Worker client factory.</param>
/// <param name="registry">Session registry; defaults to a new registry.</param>
/// <param name="metrics">Metrics collector; defaults to a new instance.</param>
/// <param name="options">Gateway options; defaults to test defaults.</param>
/// <returns>Configured session manager.</returns>
private static SessionManager CreateManager(
ISessionWorkerClientFactory factory,
ISessionRegistry? registry = null,
GatewayMetrics? metrics = null,
GatewayOptions? options = null,
TimeProvider? timeProvider = null)
{
return new SessionManager(
registry ?? new SessionRegistry(),
factory,
Options.Create(options ?? CreateOptions()),
metrics ?? new GatewayMetrics(),
timeProvider);
}
private static GatewayOptions CreateOptions(
int maxSessions = 64,
int defaultLeaseSeconds = 1800)
{
return new GatewayOptions
{
Sessions = new SessionOptions
{
DefaultCommandTimeoutSeconds = 30,
MaxSessions = maxSessions,
DefaultLeaseSeconds = defaultLeaseSeconds,
},
Worker = new WorkerOptions
{
StartupTimeoutSeconds = 30,
ShutdownTimeoutSeconds = 10,
},
};
}
private static SessionOpenRequest CreateOpenRequest()
{
return new SessionOpenRequest(
RequestedBackend: null,
ClientSessionName: "test-session",
ClientCorrelationId: "client-correlation-1",
CommandTimeout: Duration.FromTimeSpan(TimeSpan.FromSeconds(5)));
}
private static WorkerCommand CreateCommand(MxCommandKind kind)
{
return new WorkerCommand
{
Command = new MxCommand
{
Kind = kind,
},
};
}
private sealed class FakeSessionWorkerClientFactory(IWorkerClient workerClient) : ISessionWorkerClientFactory
{
/// <summary>Gets the list of observed session states during worker creation.</summary>
public List<string> ObservedStates { get; } = [];
/// <summary>Gets or sets a value indicating whether to apply lifecycle transitions during worker creation.</summary>
public bool ApplyLifecycleTransitions { get; init; }
/// <inheritdoc />
public Task<IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken)
{
ObservedStates.Add(session.State.ToString());
if (ApplyLifecycleTransitions)
{
session.TransitionTo(SessionState.WaitingForPipe);
ObservedStates.Add(session.State.ToString());
session.TransitionTo(SessionState.Handshaking);
ObservedStates.Add(session.State.ToString());
session.TransitionTo(SessionState.InitializingWorker);
ObservedStates.Add(session.State.ToString());
}
return Task.FromResult(workerClient);
}
}
private sealed class QueueingSessionWorkerClientFactory : ISessionWorkerClientFactory
{
private readonly Queue<IWorkerClient> _workerClients;
/// <summary>Initializes a new instance of the <see cref="QueueingSessionWorkerClientFactory"/> class.</summary>
/// <param name="workerClients">Array of worker clients to queue.</param>
public QueueingSessionWorkerClientFactory(params IWorkerClient[] workerClients)
{
_workerClients = new Queue<IWorkerClient>(workerClients);
}
/// <inheritdoc />
public Task<IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken)
{
return Task.FromResult(_workerClients.Dequeue());
}
}
private sealed class FailingSessionWorkerClientFactory : ISessionWorkerClientFactory
{
/// <inheritdoc />
public Task<IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken)
{
throw new InvalidOperationException("worker startup failed");
}
}
private sealed class FakeWorkerClient : IWorkerClient
{
/// <summary>Gets the session ID for the fake worker client.</summary>
public string SessionId { get; init; } = "session-1";
/// <summary>Gets the process ID for the fake worker client.</summary>
public int? ProcessId { get; init; } = 1234;
/// <summary>Gets or sets the state of the fake worker client.</summary>
public WorkerClientState State { get; set; } = WorkerClientState.Ready;
/// <summary>Gets the last heartbeat timestamp for the fake worker client.</summary>
public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>Gets the number of times invoke was called on the fake worker client.</summary>
public int InvokeCount { get; private set; }
/// <summary>Gets the number of times shutdown was called on the fake worker client.</summary>
public int ShutdownCount { get; private set; }
/// <summary>Gets the number of times kill was called on the fake worker client.</summary>
public int KillCount { get; private set; }
/// <summary>Gets the number of times dispose was called on the fake worker client.</summary>
public int DisposeCount { get; private set; }
/// <summary>Gets the exception to throw when shutdown is called, if any.</summary>
public Exception? ShutdownException { get; init; }
/// <summary>Gets a value indicating whether to block shutdown on the fake worker client.</summary>
public bool BlockShutdown { get; init; }
/// <summary>Gets the last command invoked on the fake worker client.</summary>
public WorkerCommand? LastCommand { get; private set; }
/// <summary>Gets the reply to return for invoke calls on the fake worker client.</summary>
public WorkerCommandReply? InvokeReply { get; init; }
private TaskCompletionSource ShutdownStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
private TaskCompletionSource ShutdownReleased { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken)
{
InvokeCount++;
LastCommand = command;
if (InvokeReply is not null)
{
return Task.FromResult(InvokeReply);
}
MxCommandKind kind = command.Command?.Kind ?? MxCommandKind.Unspecified;
return Task.FromResult(new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = SessionId,
CorrelationId = "correlation-1",
Kind = kind,
},
});
}
/// <inheritdoc />
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
/// <inheritdoc />
public async Task ShutdownAsync(
TimeSpan timeout,
CancellationToken cancellationToken)
{
ShutdownCount++;
if (ShutdownException is not null)
{
throw ShutdownException;
}
if (BlockShutdown)
{
ShutdownStarted.TrySetResult();
await ShutdownReleased.Task.WaitAsync(cancellationToken);
}
State = WorkerClientState.Closed;
}
/// <inheritdoc />
public void Kill(string reason)
{
KillCount++;
State = WorkerClientState.Faulted;
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
DisposeCount++;
return ValueTask.CompletedTask;
}
/// <summary>Waits for shutdown to start on the fake worker client.</summary>
public Task WaitForShutdownStartAsync()
{
return ShutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
/// <summary>Releases the shutdown block on the fake worker client.</summary>
public void ReleaseShutdown()
{
ShutdownReleased.TrySetResult();
}
}
}
@@ -0,0 +1,391 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;
using ZB.MOM.WW.MxGateway.Tests.Gateway.Workers.Fakes;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
private readonly List<IWorkerTaskLauncher> _launchers = [];
/// <summary>
/// Awaits every scripted worker task so an unhandled exception fails the owning test
/// instead of surfacing later as an unobserved <see cref="TaskScheduler.UnobservedTaskException"/>.
/// </summary>
public async ValueTask DisposeAsync()
{
foreach (IWorkerTaskLauncher launcher in _launchers)
{
await launcher.ObserveWorkerTaskAsync(TestTimeout);
}
}
/// <summary>Verifies that the factory creates a ready worker client with a scripted fake worker process.</summary>
[Fact]
public async Task CreateAsync_WithScriptedFakeWorker_ReturnsReadyClient()
{
ScriptedFakeWorkerProcessLauncher launcher = Track(new ScriptedFakeWorkerProcessLauncher());
using GatewayMetrics metrics = new();
SessionWorkerClientFactory factory = new(
launcher,
Options.Create(CreateOptions()),
metrics,
NullLoggerFactory.Instance);
GatewaySession session = CreateSession();
await using IWorkerClient workerClient = await factory.CreateAsync(
session,
CancellationToken.None);
Assert.Equal(WorkerClientState.Ready, workerClient.State);
Assert.Equal(ScriptedFakeWorkerProcessLauncher.ProcessId, workerClient.ProcessId);
Assert.NotNull(launcher.Harness);
Task<WorkerCommandReply> invokeTask = workerClient.InvokeAsync(
CreateCommand(MxCommandKind.Ping),
TestTimeout,
CancellationToken.None);
WorkerEnvelope commandEnvelope = await launcher.Harness.ReadCommandAsync();
await launcher.Harness.ReplyToCommandAsync(commandEnvelope);
WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout);
Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind);
Assert.Equal(ProtocolStatusCode.Ok, reply.Reply.ProtocolStatus.Code);
}
/// <summary>Verifies that a failed fake worker startup throws a worker client exception.</summary>
[Fact]
public async Task CreateAsync_WhenFakeWorkerStartupFails_ThrowsWorkerClientException()
{
FailingStartupWorkerProcessLauncher launcher = Track(new FailingStartupWorkerProcessLauncher());
using GatewayMetrics metrics = new();
SessionWorkerClientFactory factory = new(
launcher,
Options.Create(CreateOptions()),
metrics,
NullLoggerFactory.Instance);
GatewaySession session = CreateSession();
WorkerClientException exception = await Assert.ThrowsAsync<WorkerClientException>(
async () => await factory.CreateAsync(session, CancellationToken.None).WaitAsync(TestTimeout));
Assert.Equal(WorkerClientErrorCode.ProtocolViolation, exception.ErrorCode);
Assert.True(launcher.Process.IsDisposed);
}
/// <summary>Verifies that a worker that never sends ready times out and is killed.</summary>
[Fact]
public async Task CreateAsync_WhenFakeWorkerNeverSendsReady_TimesOutAndKillsWorker()
{
NeverReadyWorkerProcessLauncher launcher = Track(new NeverReadyWorkerProcessLauncher());
using GatewayMetrics metrics = new();
SessionWorkerClientFactory factory = new(
launcher,
Options.Create(CreateOptions(startupTimeoutSeconds: 1)),
metrics,
NullLoggerFactory.Instance);
GatewaySession session = CreateSession(startupTimeout: TimeSpan.FromSeconds(1));
TimeoutException exception = await Assert.ThrowsAsync<TimeoutException>(
async () => await factory.CreateAsync(session, CancellationToken.None).WaitAsync(TestTimeout));
Assert.Contains("did not complete startup", exception.Message);
Assert.Equal(1, launcher.Process.KillCount);
Assert.True(launcher.Process.IsDisposed);
}
private static GatewayOptions CreateOptions(int startupTimeoutSeconds = 5)
{
return new GatewayOptions
{
Worker = new WorkerOptions
{
StartupTimeoutSeconds = startupTimeoutSeconds,
ShutdownTimeoutSeconds = 5,
HeartbeatIntervalSeconds = 30,
HeartbeatGraceSeconds = 30,
MaxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes,
},
Events = new EventOptions
{
QueueCapacity = 16,
},
};
}
private static GatewaySession CreateSession(TimeSpan? startupTimeout = null)
{
return new GatewaySession(
FakeWorkerHarness.DefaultSessionId,
GatewayContractInfo.DefaultBackendName,
$"mxaccessgw-session-fake-worker-{Guid.NewGuid():N}",
FakeWorkerHarness.DefaultNonce,
"test-client",
"fake-worker-session-test",
"client-correlation-1",
startupTimeout ?? TestTimeout,
TestTimeout,
TestTimeout,
DateTimeOffset.UtcNow);
}
private static WorkerCommand CreateCommand(MxCommandKind kind)
{
return new WorkerCommand
{
Command = new MxCommand
{
Kind = kind,
},
};
}
private T Track<T>(T launcher)
where T : IWorkerTaskLauncher
{
_launchers.Add(launcher);
return launcher;
}
/// <summary>
/// A fake worker launcher that runs a scripted worker on a background task and exposes
/// that task so the owning test observes it rather than leaking an unobserved fault.
/// </summary>
private interface IWorkerTaskLauncher : IWorkerProcessLauncher
{
/// <summary>
/// Awaits the scripted worker task within the timeout, swallowing only the pipe
/// teardown faults expected when the worker client kills or disposes the worker.
/// </summary>
/// <param name="timeout">Maximum time to wait for the worker task.</param>
Task ObserveWorkerTaskAsync(TimeSpan timeout);
}
/// <summary>
/// Awaits a scripted worker task, treating cancellation and pipe-disconnect I/O faults as
/// the expected outcome of the worker client tearing the worker down, and rethrowing anything else.
/// </summary>
private static async Task ObserveWorkerTaskAsync(Task workerTask, TimeSpan timeout)
{
try
{
await workerTask.WaitAsync(timeout).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected: the worker client cancelled the scripted worker during teardown.
}
catch (IOException)
{
// Expected: the gateway pipe was closed when the worker client disposed.
}
}
/// <summary>Fake worker launcher that connects a scripted fake worker harness.</summary>
private sealed class ScriptedFakeWorkerProcessLauncher : IWorkerTaskLauncher
{
/// <summary>The fake process ID used by the scripted launcher.</summary>
public const int ProcessId = 2468;
private readonly FakeWorkerProcess _process = new(ProcessId);
/// <summary>Gets the connected fake worker harness.</summary>
public FakeWorkerHarness? Harness { get; private set; }
/// <summary>Gets the scripted worker task.</summary>
public Task WorkerTask { get; private set; } = Task.CompletedTask;
/// <inheritdoc />
public Task<WorkerProcessHandle> LaunchAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken = default)
{
WorkerTask = RunWorkerAsync(request, cancellationToken);
return Task.FromResult(CreateHandle(_process));
}
/// <inheritdoc />
public Task ObserveWorkerTaskAsync(TimeSpan timeout) =>
SessionWorkerClientFactoryFakeWorkerTests.ObserveWorkerTaskAsync(WorkerTask, timeout);
private async Task RunWorkerAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken)
{
Harness = await FakeWorkerHarness.ConnectToGatewayPipeAsync(
request.SessionId,
request.Nonce,
request.PipeName,
request.ProtocolVersion,
cancellationToken: cancellationToken).ConfigureAwait(false);
await Harness.CompleteStartupAsync(ProcessId, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
/// <summary>Fake worker launcher that fails during startup with protocol version mismatch.</summary>
private sealed class FailingStartupWorkerProcessLauncher : IWorkerTaskLauncher
{
/// <summary>Gets the fake worker process.</summary>
public FakeWorkerProcess Process { get; } = new(processId: 3579);
/// <summary>Gets the scripted worker task.</summary>
public Task WorkerTask { get; private set; } = Task.CompletedTask;
/// <inheritdoc />
public Task<WorkerProcessHandle> LaunchAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken = default)
{
WorkerTask = RunWorkerAsync(request, cancellationToken);
return Task.FromResult(CreateHandle(Process));
}
/// <inheritdoc />
public Task ObserveWorkerTaskAsync(TimeSpan timeout) =>
SessionWorkerClientFactoryFakeWorkerTests.ObserveWorkerTaskAsync(WorkerTask, timeout);
private async Task RunWorkerAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken)
{
await using FakeWorkerHarness harness = await FakeWorkerHarness.ConnectToGatewayPipeAsync(
request.SessionId,
request.Nonce,
request.PipeName,
request.ProtocolVersion,
cancellationToken: cancellationToken).ConfigureAwait(false);
_ = await harness.ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false);
await harness.SendWorkerHelloAsync(
workerProcessId: Process.Id,
workerProtocolVersion: request.ProtocolVersion + 1,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
/// <summary>Fake worker launcher that never completes startup, simulating a hung worker.</summary>
private sealed class NeverReadyWorkerProcessLauncher : IWorkerTaskLauncher
{
private readonly CancellationTokenSource _stop = new();
/// <summary>Gets the fake worker process.</summary>
public FakeWorkerProcess Process { get; } = new(processId: 4680);
/// <summary>Gets the scripted worker task.</summary>
public Task WorkerTask { get; private set; } = Task.CompletedTask;
/// <inheritdoc />
public Task<WorkerProcessHandle> LaunchAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken = default)
{
WorkerTask = RunWorkerAsync(request);
return Task.FromResult(CreateHandle(Process));
}
/// <inheritdoc />
public async Task ObserveWorkerTaskAsync(TimeSpan timeout)
{
// The scripted worker parks on an infinite delay; cancel it so disposal observes
// the task instead of leaking it as an unobserved fault.
await _stop.CancelAsync().ConfigureAwait(false);
await SessionWorkerClientFactoryFakeWorkerTests
.ObserveWorkerTaskAsync(WorkerTask, timeout)
.ConfigureAwait(false);
_stop.Dispose();
}
private async Task RunWorkerAsync(WorkerProcessLaunchRequest request)
{
await using FakeWorkerHarness harness = await FakeWorkerHarness.ConnectToGatewayPipeAsync(
request.SessionId,
request.Nonce,
request.PipeName,
request.ProtocolVersion,
cancellationToken: _stop.Token).ConfigureAwait(false);
_ = await harness.ReadGatewayEnvelopeAsync(_stop.Token).ConfigureAwait(false);
await harness.SendWorkerHelloAsync(
workerProcessId: Process.Id,
workerProtocolVersion: request.ProtocolVersion,
cancellationToken: _stop.Token).ConfigureAwait(false);
await Task.Delay(Timeout.InfiniteTimeSpan, _stop.Token).ConfigureAwait(false);
}
}
private static WorkerProcessHandle CreateHandle(IWorkerProcess process)
{
return new WorkerProcessHandle(
process,
new WorkerProcessCommandLine("fake-worker.exe", []),
DateTimeOffset.UtcNow);
}
/// <summary>
/// Fake worker process for testing process lifecycle. <see cref="WaitForExitAsync"/>
/// awaits a <see cref="TaskCompletionSource"/> completed only by
/// <see cref="Kill"/> or <see cref="MarkExited"/>, so a caller observing
/// completion can trust that exit actually happened — bringing this fake into
/// parity with the smoke-test variant in <c>GatewayEndToEndFakeWorkerSmokeTests</c>
/// (Tests-015 / Tests-023). This removes the latent regression vector where a
/// future <c>Assert.True(launcher.Process.HasExited)</c> in this file would
/// pass spuriously regardless of whether the worker truly exited.
/// </summary>
private sealed class FakeWorkerProcess(int processId) : IWorkerProcess
{
private readonly TaskCompletionSource _exited = new(TaskCreationOptions.RunContinuationsAsynchronously);
private bool _disposed;
/// <inheritdoc />
public int Id { get; } = processId;
/// <summary>Gets a value indicating whether the process has exited.</summary>
public bool HasExited { get; private set; }
/// <summary>Gets the process exit code, or null if the process has not exited.</summary>
public int? ExitCode { get; private set; }
/// <summary>Gets the number of times the Kill method was called.</summary>
public int KillCount { get; private set; }
/// <inheritdoc />
public ValueTask WaitForExitAsync(CancellationToken cancellationToken)
{
return new ValueTask(_exited.Task.WaitAsync(cancellationToken));
}
/// <inheritdoc />
public void Kill(bool entireProcessTree)
{
KillCount++;
MarkExited(-1);
}
/// <inheritdoc />
public void Dispose()
{
_disposed = true;
}
/// <summary>Gets a value indicating whether this process has been disposed.</summary>
public bool IsDisposed => _disposed;
/// <summary>Marks the process as exited with the specified exit code.</summary>
/// <param name="exitCode">The process exit code.</param>
public void MarkExited(int exitCode)
{
HasExited = true;
ExitCode = exitCode;
_exited.TrySetResult();
}
}
}