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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+391
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user