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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 16:22:23 -04:00
parent 867bf18116
commit dc9c0c950c
491 changed files with 32854 additions and 8414 deletions
@@ -0,0 +1,284 @@
using System.Runtime.CompilerServices;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Server.Workers;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
/// <summary>
/// Concurrency and disposal regression tests for <see cref="GatewaySession"/>.
/// Server-015 and Server-016 audited the split lock discipline between
/// <c>_syncRoot</c> (state transitions) and <c>_closeLock</c> (close serialization)
/// and the un-gated <c>DisposeAsync</c>; these tests pin the post-fix behavior.
/// </summary>
public sealed class GatewaySessionTests
{
/// <summary>
/// Server-015 regression. A <c>TransitionTo(Ready)</c> issued after
/// <see cref="GatewaySession.CloseAsync"/> has set <see cref="SessionState.Closing"/>
/// must not flip the session back to <see cref="SessionState.Ready"/>. The
/// blocking worker shutdown keeps <c>CloseAsync</c> parked between the
/// <c>Closing</c> write and the <c>Closed</c> write, which is precisely the
/// window the audit identified.
/// </summary>
[Fact]
public async Task TransitionTo_AfterCloseStarted_DoesNotOverwriteClosing()
{
BlockingShutdownWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
Task<SessionCloseResult> closeTask = session.CloseAsync("test-close", CancellationToken.None);
await workerClient.WaitForShutdownStartAsync();
// Close has set _state = Closing under _syncRoot and is parked inside
// worker.ShutdownAsync. A concurrent transition (e.g. a late
// SessionWorkerClientFactory lifecycle callback) must not revive the session.
Assert.Equal(SessionState.Closing, session.State);
session.TransitionTo(SessionState.Ready);
Assert.Equal(SessionState.Closing, session.State);
workerClient.ReleaseShutdown();
SessionCloseResult result = await closeTask;
Assert.Equal(SessionState.Closed, result.FinalState);
Assert.Equal(SessionState.Closed, session.State);
await session.DisposeAsync();
}
/// <summary>
/// Server-015 regression. Once <see cref="GatewaySession.CloseAsync"/> finishes,
/// <see cref="GatewaySession.MarkFaulted"/> must not be able to move the
/// session out of <see cref="SessionState.Closed"/> either — the close path's
/// terminal write goes through the same <c>_syncRoot</c> the rest of the state
/// machine uses, so the existing "Closed is terminal" invariant holds.
/// </summary>
[Fact]
public async Task MarkFaulted_AfterCloseCompletes_DoesNotResurrectSession()
{
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
await session.CloseAsync("test-close", CancellationToken.None);
Assert.Equal(SessionState.Closed, session.State);
session.MarkFaulted("late-fault");
Assert.Equal(SessionState.Closed, session.State);
await session.DisposeAsync();
}
/// <summary>
/// Server-028 regression. A <see cref="GatewaySession.MarkFaulted"/> issued
/// while <see cref="GatewaySession.CloseAsync"/> is parked between its
/// <c>Closing</c> and <c>Closed</c> writes must not break the close path's
/// terminal contract: the in-flight close runs to <c>Closed</c>, the fault
/// reason is preserved on <see cref="GatewaySession.FinalFault"/>, and the
/// session does not get stuck in <see cref="SessionState.Faulted"/>. The
/// state machine documents "Closing only allows a transition to Closed or
/// Faulted" — this test pins the resolved end state so a future tightening
/// of <c>MarkFaulted</c> cannot silently regress it.
/// </summary>
[Fact]
public async Task MarkFaulted_DuringInFlightClose_PreservesFaultButYieldsToClose()
{
BlockingShutdownWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
Task<SessionCloseResult> closeTask = session.CloseAsync("test-close", CancellationToken.None);
await workerClient.WaitForShutdownStartAsync();
// Close has set _state = Closing under _syncRoot and is parked inside
// worker.ShutdownAsync. Fault the session from another thread while parked.
Assert.Equal(SessionState.Closing, session.State);
session.MarkFaulted("concurrent-fault");
workerClient.ReleaseShutdown();
SessionCloseResult result = await closeTask;
// Close still wins — Closed is terminal — but the fault reason is preserved
// so observers see the original cause once the session settles.
Assert.Equal(SessionState.Closed, result.FinalState);
Assert.Equal(SessionState.Closed, session.State);
Assert.Equal("concurrent-fault", session.FinalFault);
await session.DisposeAsync();
}
/// <summary>
/// Server-016 regression. <see cref="GatewaySession.DisposeAsync"/> must wait
/// for an in-flight <see cref="GatewaySession.CloseAsync"/> before disposing
/// its semaphore. Without the fix, the close's <c>_closeLock.Release()</c>
/// would race the dispose and raise <see cref="ObjectDisposedException"/>.
/// </summary>
[Fact]
public async Task DisposeAsync_WhileCloseInFlight_WaitsForCloseAndDoesNotThrow()
{
BlockingShutdownWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
Task<SessionCloseResult> closeTask = session.CloseAsync("test-close", CancellationToken.None);
await workerClient.WaitForShutdownStartAsync();
// Start disposing while close is still parked inside worker.ShutdownAsync.
ValueTask disposeTask = session.DisposeAsync();
// Now release the worker shutdown so close can complete.
workerClient.ReleaseShutdown();
// Both must complete cleanly — the close's Release() must run before the
// dispose actually tears the semaphore down.
SessionCloseResult result = await closeTask;
await disposeTask;
Assert.Equal(SessionState.Closed, result.FinalState);
Assert.Equal(1, workerClient.ShutdownCount);
// Worker dispose ran exactly once even with the close/dispose interleave.
Assert.Equal(1, workerClient.DisposeCount);
}
/// <summary>
/// Double-dispose is tolerated: the second call must swallow
/// <see cref="ObjectDisposedException"/> from the already-disposed semaphore
/// rather than propagating it.
/// </summary>
[Fact]
public async Task DisposeAsync_CalledTwice_DoesNotThrow()
{
FakeWorkerClient workerClient = new();
GatewaySession session = CreateReadySession(workerClient);
await session.CloseAsync("test-close", CancellationToken.None);
await session.DisposeAsync();
// No second exception — the dispose's defensive ObjectDisposedException catch
// covers the doubled call path that SessionManager.ShutdownAsync could trigger
// if it re-removed a session.
await session.DisposeAsync();
}
private static GatewaySession CreateReadySession(IWorkerClient workerClient)
{
GatewaySession session = new(
sessionId: "session-test",
backendName: "mxaccess",
pipeName: "mxaccess-gateway-1-session-test",
nonce: "nonce",
clientIdentity: "client-1",
clientSessionName: "test-session",
clientCorrelationId: "client-correlation-1",
commandTimeout: TimeSpan.FromSeconds(5),
startupTimeout: TimeSpan.FromSeconds(5),
shutdownTimeout: TimeSpan.FromSeconds(5),
leaseDuration: TimeSpan.FromMinutes(30),
openedAt: DateTimeOffset.UtcNow);
session.AttachWorkerClient(workerClient);
session.MarkReady();
return session;
}
/// <summary>
/// Minimal worker client that parks <see cref="ShutdownAsync"/> until the test
/// explicitly releases it. Used to keep <see cref="GatewaySession.CloseAsync"/>
/// stuck between its <c>Closing</c> and <c>Closed</c> writes so the test can
/// observe and act on the intermediate state.
/// </summary>
private sealed class BlockingShutdownWorkerClient : IWorkerClient
{
private readonly TaskCompletionSource _shutdownStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _shutdownReleased = new(TaskCreationOptions.RunContinuationsAsynchronously);
public string SessionId { get; } = "session-test";
public int? ProcessId { get; } = 1234;
public WorkerClientState State { get; private set; } = WorkerClientState.Ready;
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
public int ShutdownCount { get; private set; }
public int DisposeCount { get; private set; }
public Task WaitForShutdownStartAsync()
{
return _shutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public void ReleaseShutdown()
{
_shutdownReleased.TrySetResult();
}
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
}
public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
ShutdownCount++;
_shutdownStarted.TrySetResult();
await _shutdownReleased.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
State = WorkerClientState.Closed;
}
public void Kill(string reason)
{
State = WorkerClientState.Faulted;
}
public ValueTask DisposeAsync()
{
DisposeCount++;
return ValueTask.CompletedTask;
}
}
private sealed class FakeWorkerClient : IWorkerClient
{
public string SessionId { get; } = "session-test";
public int? ProcessId { get; } = 1234;
public WorkerClientState State { get; } = WorkerClientState.Ready;
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
public int DisposeCount { get; private set; }
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
}
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
public void Kill(string reason)
{
}
public ValueTask DisposeAsync()
{
DisposeCount++;
return ValueTask.CompletedTask;
}
}
}