Code-review 2026-05-20 sweep: re-review at 1cd51bb, resolve 72 findings across all 11 modules

Re-reviewed every module/client against the 10-category checklist
(REVIEW-PROCESS.md) at commit 1cd51bb, filed 72 new findings, and
fixed them in three priority waves (3 High, 17 Medium, 52 Low).

Highs
- Server-017: enumerate AcknowledgeAlarm / QueryActiveAlarms in
  GatewayGrpcScopeResolver so non-admin keys can use them; document
  the mapping in docs/Authorization.md; add interceptor tests.
- Client.Java-013: add the five missing bulk-method stubs to the
  CLI FakeSession so the test module compiles on a clean tree.
- Client.Rust-013: fix the clippy::doc_lazy_continuation regression
  in generated tonic code by reformatting the ReadBulkCommand proto
  comment and scoping a #![allow(...)] to the generated submodules.

Mediums (highlights)
- Server: unify GatewaySession state-lock discipline (-015) and
  make DisposeAsync race-safe against in-flight CloseAsync (-016);
  add constraint-enforcement test coverage for the bulk-plan path
  (-021).
- Worker: introduce StaRuntimeShutdownException so RunAlarmPollLoop
  can distinguish graceful shutdown from a real STA-affinity
  violation (-016); have the watchdog skip StaHung while
  CurrentCommandCorrelationId is non-empty so a legitimate slow
  ReadBulk no longer self-faults (-017).
- Tests: add per-method round-trip + cancellation coverage for the
  11 GatewaySession bulk methods (-013); replace the real TCP probe
  in GalaxyHierarchyCacheTests with an IGalaxyRepository fake
  (-016).
- IntegrationTests: drive the StreamEvents writer in the live Write
  test and assert OnWriteComplete (-012); add live tests for
  Unadvise/RemoveItem/Unregister ordering, WriteSecured, and
  abnormal worker exit (-014).
- Worker.Tests: replace MxAccessSession reflection with an internal
  CreateForTesting factory (-016); cover WorkerCancel and
  unexpected-body envelope branches (-017).
- Client.Java: cancel MxEventStream when close() races
  beforeStart() (-014); return a CancellingCompletableFuture that
  actually forwards cancellation through .thenApply chains (-015).
- Client.Python: drop the silent localhost-plaintext downgrade in
  the CLI; require explicit --plaintext (-013).
- Client.Rust: stop bench-read-bulk from polluting success-latency
  histograms with failed-call durations (-015); add coverage for
  the five MalformedReply paths, the bulk-write helpers, the
  Error::Unavailable mapping, and the unary-fault path (-016).
- Contracts: extend docs/Contracts.md with the bulk read/write
  command family (-009).

Lows (highlights)
- Server: cap GalaxyGlobMatcher.RegexCache; align
  WorkerAlarmRpcDispatcher missing-session handling; drop the
  duplicate dashboard @page routes; refresh IAlarmRpcDispatcher
  XML doc.
- Worker: surface SetXmlAlarmQuery COM failures; remove dead
  subscriptionExpression / ExecutingCommand arms; preserve
  factory-supplied runtime sessions; split MxAlarmSnapshot.cs into
  three files.
- Tests: dispose the WebApplication in seven test classes; rebuild
  FakeWorkerProcess.WaitForExitAsync against a real TaskCompletion
  source; switch the heartbeat-expires test to ManualTimeProvider;
  add InvariantCulture to the remaining DateTimeOffset.Parse sites;
  document GalaxyFilterInputSafetyTests in GatewayTesting.md.
- IntegrationTests: comment fixes, RecordingServerStreamWriter
  IDisposable, class-level [Trait], single-source ZB default
  connection string.
- Worker.Tests: replace silent-return gating with LiveMxAccessFact
  so absent env vars SKIP not pass; PascalCase rename of probe
  [Fact]s; deterministic deadline test; new frame-protocol error
  tests; ComputeTransitions diff-coverage; relocate dev-rig probes
  to Probes/.
- Contracts: add round-trip coverage and per-field redaction /
  Galaxy-identifier comments to the protos.
- Client.Dotnet: introduce clients/dotnet/Directory.Build.props so
  TreatWarningsAsErrors / analysers apply; document
  DiscoverHierarchyOptions and IMxGatewayCliClient; require typed
  bulk-read handles in CLI; surface AcknowledgeAlarm transport
  faults through Translate().
- Client.Go: kill dead code in alarms_test / fakeGalaxyServer /
  runWriteBulkVariant; document the six new subcommands in
  writeUsage; drain galaxy-watch events on limit; switch io.EOF
  comparisons to errors.Is.
- Client.Java: shared shutdown helpers + new shutdownTimeout
  option; regex-based credential redaction; Long.toUnsignedString
  for uint64 sequence; doc fixes.
- Client.Python: combine duplicate imports; add coverage for
  _percentile / bench-read-bulk / MAX_AGGREGATE_EVENTS /
  _api_key_from_env; populate pyproject metadata and ship py.typed.
- Client.Rust: expose next_correlation_id() so CLI ping/close
  stop hard-coding correlation IDs; resync RustClientDesign.md
  with the current Session / Error surface and CLI subcommand set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-20 09:46:47 -04:00
parent 1cd51bbda3
commit a0203503a7
122 changed files with 8723 additions and 757 deletions
@@ -3053,6 +3053,14 @@ namespace MxGateway.Contracts.Proto.Galaxy {
/// <summary>Field number for the "mx_data_type" field.</summary>
public const int MxDataTypeFieldNumber = 3;
private int mxDataType_;
/// <summary>
/// Raw Galaxy SQL `dbo.data_type` identifier, passed through unchanged.
/// This is NOT a member of `mxaccess_gateway.v1.MxDataType` — Galaxy's
/// type enumeration is distinct from MXAccess's wire data-type enum and
/// the two must not be cast or compared. The GalaxyRepository service is
/// metadata-only and deliberately does not share types with
/// mxaccess_gateway.proto. See docs/GalaxyRepository.md.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public int MxDataType {
@@ -3065,6 +3073,10 @@ namespace MxGateway.Contracts.Proto.Galaxy {
/// <summary>Field number for the "data_type_name" field.</summary>
public const int DataTypeNameFieldNumber = 4;
private string dataTypeName_ = "";
/// <summary>
/// Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
/// "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string DataTypeName {
@@ -3113,6 +3125,11 @@ namespace MxGateway.Contracts.Proto.Galaxy {
/// <summary>Field number for the "mx_attribute_category" field.</summary>
public const int MxAttributeCategoryFieldNumber = 8;
private int mxAttributeCategory_;
/// <summary>
/// Raw Galaxy SQL attribute-category identifier, passed through unchanged.
/// Galaxy-specific; not mapped to any gateway enum. See
/// docs/GalaxyRepository.md.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public int MxAttributeCategory {
@@ -3125,6 +3142,11 @@ namespace MxGateway.Contracts.Proto.Galaxy {
/// <summary>Field number for the "security_classification" field.</summary>
public const int SecurityClassificationFieldNumber = 9;
private int securityClassification_;
/// <summary>
/// Raw Galaxy SQL security-classification identifier, passed through
/// unchanged. Galaxy-specific; not mapped to any gateway enum. See
/// docs/GalaxyRepository.md.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public int SecurityClassification {
@@ -13787,6 +13787,10 @@ namespace MxGateway.Contracts.Proto {
/// <summary>Field number for the "value" field.</summary>
public const int ValueFieldNumber = 4;
private global::MxGateway.Contracts.Proto.MxValue value_;
/// <summary>
/// Credential-sensitive write value. Implementations must not log this field
/// unless an explicit redacted value-logging path is enabled.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public global::MxGateway.Contracts.Proto.MxValue Value {
@@ -14334,6 +14338,10 @@ namespace MxGateway.Contracts.Proto {
/// <summary>Field number for the "value" field.</summary>
public const int ValueFieldNumber = 4;
private global::MxGateway.Contracts.Proto.MxValue value_;
/// <summary>
/// Credential-sensitive write value. Implementations must not log this field
/// unless an explicit redacted value-logging path is enabled.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public global::MxGateway.Contracts.Proto.MxValue Value {
@@ -14613,6 +14621,7 @@ namespace MxGateway.Contracts.Proto {
/// <summary>
/// Bulk Read — snapshot the current value for each requested tag. MXAccess COM
/// has no synchronous Read; the worker implements ReadBulk as:
///
/// - If the tag is already in the session's item registry AND that item is
/// currently advised AND the worker has a cached OnDataChange for it, the
/// reply returns the cached value WITHOUT modifying the existing
@@ -14621,6 +14630,7 @@ namespace MxGateway.Contracts.Proto {
/// Advise, wait up to `timeout_ms` for the first OnDataChange, then
/// UnAdvise + RemoveItem before returning. The session is left exactly
/// as it was before the call (was_cached = false).
///
/// `timeout_ms == 0` uses the gateway-configured default (1000 ms).
/// </summary>
[global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")]
@@ -117,12 +117,26 @@ message GalaxyObject {
message GalaxyAttribute {
string attribute_name = 1;
string full_tag_reference = 2;
// Raw Galaxy SQL `dbo.data_type` identifier, passed through unchanged.
// This is NOT a member of `mxaccess_gateway.v1.MxDataType` — Galaxy's
// type enumeration is distinct from MXAccess's wire data-type enum and
// the two must not be cast or compared. The GalaxyRepository service is
// metadata-only and deliberately does not share types with
// mxaccess_gateway.proto. See docs/GalaxyRepository.md.
int32 mx_data_type = 3;
// Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
// "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
string data_type_name = 4;
bool is_array = 5;
int32 array_dimension = 6;
bool array_dimension_present = 7;
// Raw Galaxy SQL attribute-category identifier, passed through unchanged.
// Galaxy-specific; not mapped to any gateway enum. See
// docs/GalaxyRepository.md.
int32 mx_attribute_category = 8;
// Raw Galaxy SQL security-classification identifier, passed through
// unchanged. Galaxy-specific; not mapped to any gateway enum. See
// docs/GalaxyRepository.md.
int32 security_classification = 9;
bool is_historized = 10;
bool is_alarm = 11;
@@ -393,6 +393,8 @@ message WriteSecuredBulkEntry {
int32 item_handle = 1;
int32 current_user_id = 2;
int32 verifier_user_id = 3;
// Credential-sensitive write value. Implementations must not log this field
// unless an explicit redacted value-logging path is enabled.
MxValue value = 4;
}
@@ -407,12 +409,15 @@ message WriteSecured2BulkEntry {
int32 item_handle = 1;
int32 current_user_id = 2;
int32 verifier_user_id = 3;
// Credential-sensitive write value. Implementations must not log this field
// unless an explicit redacted value-logging path is enabled.
MxValue value = 4;
MxValue timestamp_value = 5;
}
// Bulk Read — snapshot the current value for each requested tag. MXAccess COM
// has no synchronous Read; the worker implements ReadBulk as:
//
// - If the tag is already in the session's item registry AND that item is
// currently advised AND the worker has a cached OnDataChange for it, the
// reply returns the cached value WITHOUT modifying the existing
@@ -421,6 +426,7 @@ message WriteSecured2BulkEntry {
// Advise, wait up to `timeout_ms` for the first OnDataChange, then
// UnAdvise + RemoveItem before returning. The session is left exactly
// as it was before the call (was_cached = false).
//
// `timeout_ms == 0` uses the gateway-configured default (1000 ms).
message ReadBulkCommand {
int32 server_handle = 1;
@@ -7,10 +7,10 @@ using MxGateway.Server.Dashboard;
namespace MxGateway.IntegrationTests;
[Collection(LiveResourcesCollection.Name)]
[Trait("Category", "LiveLdap")]
public sealed class DashboardLdapLiveTests
{
[LiveLdapFact]
[Trait("Category", "LiveLdap")]
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
{
DashboardAuthenticator authenticator = CreateAuthenticator();
@@ -29,7 +29,6 @@ public sealed class DashboardLdapLiveTests
}
[LiveLdapFact]
[Trait("Category", "LiveLdap")]
public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails()
{
DashboardAuthenticator authenticator = CreateAuthenticator();
@@ -45,7 +44,6 @@ public sealed class DashboardLdapLiveTests
}
[LiveLdapFact]
[Trait("Category", "LiveLdap")]
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
{
// Exercises the LdapException branch: the user exists and the service
@@ -64,7 +62,6 @@ public sealed class DashboardLdapLiveTests
}
[LiveLdapFact]
[Trait("Category", "LiveLdap")]
public async Task AuthenticateAsync_UnknownUsername_Fails()
{
// Exercises the `candidate is null` branch: the service-account search
@@ -81,7 +78,6 @@ public sealed class DashboardLdapLiveTests
}
[LiveLdapFact]
[Trait("Category", "LiveLdap")]
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
{
// Exercises the connect-failure path: a closed loopback port produces a
@@ -3,11 +3,11 @@ using MxGateway.Server.Galaxy;
namespace MxGateway.IntegrationTests.Galaxy;
[Collection(LiveResourcesCollection.Name)]
[Trait("Category", "LiveGalaxy")]
public sealed class GalaxyRepositoryLiveTests
{
/// <summary>Verifies that the Galaxy Repository can establish a live connection to the ZB database.</summary>
[LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")]
public async Task TestConnection_AgainstZb_Succeeds()
{
GalaxyRepository repository = CreateRepository();
@@ -19,7 +19,6 @@ public sealed class GalaxyRepositoryLiveTests
/// <summary>Verifies that the last deploy time can be retrieved from the ZB database.</summary>
[LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")]
public async Task GetLastDeployTime_AgainstZb_ReturnsTimestamp()
{
GalaxyRepository repository = CreateRepository();
@@ -31,7 +30,6 @@ public sealed class GalaxyRepositoryLiveTests
/// <summary>Verifies that the hierarchy can be retrieved from the ZB database.</summary>
[LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")]
public async Task GetHierarchy_AgainstZb_ReturnsObjects()
{
GalaxyRepository repository = CreateRepository();
@@ -49,7 +47,6 @@ public sealed class GalaxyRepositoryLiveTests
/// <summary>Verifies that object attributes can be retrieved from the ZB database.</summary>
[LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")]
public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute()
{
GalaxyRepository repository = CreateRepository();
@@ -1,3 +1,5 @@
using MxGateway.Server.Galaxy;
namespace MxGateway.IntegrationTests.Galaxy;
/// <summary>Fact attribute that skips tests unless live Galaxy Repository tests are explicitly enabled.</summary>
@@ -20,8 +22,12 @@ public sealed class LiveGalaxyRepositoryFactAttribute : FactAttribute
/// <summary>Gets a value indicating whether live Galaxy Repository tests are enabled.</summary>
public static bool Enabled => IntegrationTestEnvironment.IsEnabled(EnableVariableName);
/// <summary>Gets the Galaxy Repository connection string from environment or default.</summary>
/// <summary>
/// Gets the Galaxy Repository connection string from environment or the production
/// default. The default is sourced from <see cref="GalaxyRepositoryOptions.DefaultConnectionString"/>
/// so the live-test fallback cannot drift away from the production default.
/// </summary>
public static string ConnectionString =>
Environment.GetEnvironmentVariable(ConnectionStringVariableName)
?? "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
?? GalaxyRepositoryOptions.DefaultConnectionString;
}
@@ -18,6 +18,7 @@ using Xunit.Abstractions;
namespace MxGateway.IntegrationTests;
[Collection(LiveResourcesCollection.Name)]
[Trait("Category", "LiveMxAccess")]
public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
{
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(15);
@@ -27,7 +28,6 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// Verifies that a gateway session can register, add item, advise, and stream events from live MXAccess.
/// </summary>
[LiveMxAccessFact]
[Trait("Category", "LiveMxAccess")]
public async Task GatewaySession_WithLiveWorker_RegistersAdvisesStreamsDataAndCloses()
{
string workerExecutablePath = IntegrationTestEnvironment.ResolveLiveMxAccessWorkerExecutablePath();
@@ -37,9 +37,9 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
TestWorkerProcessFactory processFactory = new(output);
await using GatewayServiceFixture fixture = new(workerExecutablePath, processFactory, output);
using RecordingServerStreamWriter<MxEvent> eventWriter = new();
string? sessionId = null;
RecordingServerStreamWriter<MxEvent>? eventWriter = null;
Task? streamTask = null;
using CancellationTokenSource streamCancellation = new();
@@ -59,7 +59,6 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code);
Assert.True(openReply.WorkerProcessId > 0);
eventWriter = new RecordingServerStreamWriter<MxEvent>();
streamTask = fixture.Service.StreamEvents(
new StreamEventsRequest { SessionId = sessionId },
eventWriter,
@@ -113,10 +112,11 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
}
/// <summary>
/// Verifies that a Write command round-trips through live MXAccess against an advised item.
/// Verifies that a Write command round-trips through live MXAccess against an advised item
/// and that the worker emits a matching <see cref="MxEventFamily.OnWriteComplete"/> event
/// — the proof of round-trip the cross-language client e2e runner relies on.
/// </summary>
[LiveMxAccessFact]
[Trait("Category", "LiveMxAccess")]
public async Task GatewaySession_WithLiveWorker_WritesValueToAdvisedItem()
{
string workerExecutablePath = IntegrationTestEnvironment.ResolveLiveMxAccessWorkerExecutablePath();
@@ -126,9 +126,11 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
TestWorkerProcessFactory processFactory = new(output);
await using GatewayServiceFixture fixture = new(workerExecutablePath, processFactory, output);
using RecordingServerStreamWriter<MxEvent> eventWriter = new();
string? sessionId = null;
Task? streamTask = null;
using CancellationTokenSource streamCancellation = new();
try
{
@@ -144,11 +146,10 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
sessionId = openReply.SessionId;
Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code);
RecordingServerStreamWriter<MxEvent> eventWriter = new();
streamTask = fixture.Service.StreamEvents(
new StreamEventsRequest { SessionId = sessionId },
eventWriter,
new TestServerCallContext());
new TestServerCallContext(streamCancellation.Token));
MxCommandReply registerReply = await fixture.Service.Invoke(
CreateRegisterRequest(sessionId),
@@ -180,16 +181,50 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
new TestServerCallContext()).ConfigureAwait(false);
LogReply("Write", writeReply);
// The gateway must always report a protocol-level status. MXAccess
// parity details (a write rejection, a secured-item failure) belong
// in hresult / statuses, not in a transport failure — the command
// itself completed its round-trip to the worker and back.
// Happy-path Write: the worker COM call succeeded so HResultConverter
// produces ProtocolStatusCode.Ok. An MXAccess rejection (a write to a
// bad item, a secured-item failure) would surface as
// ProtocolStatusCode.MxaccessFailure with a non-zero hresult — never
// as an RpcException / transport fault, because the command still
// completed its round-trip to the worker and back.
Assert.Equal(ProtocolStatusCode.Ok, writeReply.ProtocolStatus.Code);
Assert.Equal(MxCommandKind.Write, writeReply.Kind);
// Proof of round-trip: MXAccess fires OnWriteComplete (event id 2)
// after the underlying provider acknowledges the write — that is
// the event the cross-language client e2e runner asserts on. We
// scan the recorded stream (so an interleaving OnDataChange does
// not preempt the match) for an OnWriteComplete carrying the same
// server/item handles the Write command targeted.
MxEvent writeComplete = await eventWriter
.WaitForMessageAsync(
candidate => candidate.Family == MxEventFamily.OnWriteComplete
&& candidate.ServerHandle == registerReply.Register.ServerHandle
&& candidate.ItemHandle == addItemReply.AddItem.ItemHandle,
IntegrationTestEnvironment.LiveMxAccessEventTimeout,
streamCancellation.Token)
.ConfigureAwait(false);
LogEvent(writeComplete);
Assert.Equal(MxEventFamily.OnWriteComplete, writeComplete.Family);
Assert.Equal(sessionId, writeComplete.SessionId);
Assert.Equal(registerReply.Register.ServerHandle, writeComplete.ServerHandle);
Assert.Equal(addItemReply.AddItem.ItemHandle, writeComplete.ItemHandle);
// The stream task must not be in a faulted state. ShutDownAsync's
// broad catch would otherwise swallow the fault and silently let
// this Write-parity coverage pass against a broken event pipeline.
Assert.False(
streamTask.IsFaulted,
streamTask.Exception?.ToString() ?? "Event stream task faulted without an exception.");
}
finally
{
await ShutDownAsync(fixture, processFactory, sessionId, streamTask).ConfigureAwait(false);
// Cancel the stream call before draining so StreamEvents observes
// cancellation rather than blocking on the channel. Any unhandled
// stream-task fault is rethrown from ShutDownAsync into the test.
streamCancellation.Cancel();
await ShutDownAsync(fixture, processFactory, sessionId, streamTask, propagateStreamFaults: true).ConfigureAwait(false);
}
}
@@ -198,7 +233,6 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// without faulting the gateway transport, exercising the invalid-handle parity path.
/// </summary>
[LiveMxAccessFact]
[Trait("Category", "LiveMxAccess")]
public async Task GatewaySession_WithLiveWorker_InvalidHandleCommand_SurfacesFailureWithoutTransportFault()
{
string workerExecutablePath = IntegrationTestEnvironment.ResolveLiveMxAccessWorkerExecutablePath();
@@ -235,8 +269,10 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
// MXAccess parity: an invalid handle is an MXAccess-level failure.
// The command still completed its worker round-trip, so the gateway
// protocol status is Ok and the failure shows up in hresult / the
// status proxies — it must not be reported as a transport fault.
// must reply with ProtocolStatusCode.MxaccessFailure and a non-zero
// hresult carrying the COM failure (per HResultConverter) — never a
// gRPC transport fault. The assertion below just checks the status
// is not Ok; the failure detail lives in hresult / the status proxies.
Assert.NotEqual(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code);
Assert.True(
addItemReply.AddItem is null || addItemReply.AddItem.ItemHandle <= 0,
@@ -248,35 +284,411 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
}
}
/// <summary>
/// Verifies the MXAccess teardown chain: Unadvise then RemoveItem then Unregister
/// each return <see cref="ProtocolStatusCode.Ok"/>, and the worker stops emitting
/// OnDataChange events for the un-advised item. Exercises the lifecycle-ordering
/// parity CLAUDE.md singles out as a "do not synthesize" rule.
/// </summary>
[LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_UnadviseRemoveItemUnregister_TeardownOrderingParity()
{
string workerExecutablePath = IntegrationTestEnvironment.ResolveLiveMxAccessWorkerExecutablePath();
Assert.True(
File.Exists(workerExecutablePath),
$"Live MXAccess worker executable was not found at {workerExecutablePath}. Build the worker or set {IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName}.");
TestWorkerProcessFactory processFactory = new(output);
await using GatewayServiceFixture fixture = new(workerExecutablePath, processFactory, output);
using RecordingServerStreamWriter<MxEvent> eventWriter = new();
string? sessionId = null;
Task? streamTask = null;
using CancellationTokenSource streamCancellation = new();
try
{
OpenSessionReply openReply = await fixture.Service.OpenSession(
new OpenSessionRequest
{
ClientSessionName = "live-mxaccess-teardown",
ClientCorrelationId = "live-open-teardown",
CommandTimeout = Duration.FromTimeSpan(CommandTimeout),
},
new TestServerCallContext()).ConfigureAwait(false);
sessionId = openReply.SessionId;
Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code);
streamTask = fixture.Service.StreamEvents(
new StreamEventsRequest { SessionId = sessionId },
eventWriter,
new TestServerCallContext(streamCancellation.Token));
MxCommandReply registerReply = await fixture.Service.Invoke(
CreateRegisterRequest(sessionId),
new TestServerCallContext()).ConfigureAwait(false);
LogReply("Register", registerReply);
Assert.Equal(ProtocolStatusCode.Ok, registerReply.ProtocolStatus.Code);
int serverHandle = registerReply.Register.ServerHandle;
MxCommandReply addItemReply = await fixture.Service.Invoke(
CreateAddItemRequest(sessionId, serverHandle),
new TestServerCallContext()).ConfigureAwait(false);
LogReply("AddItem", addItemReply);
Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code);
int itemHandle = addItemReply.AddItem.ItemHandle;
MxCommandReply adviseReply = await fixture.Service.Invoke(
CreateAdviseRequest(sessionId, serverHandle, itemHandle),
new TestServerCallContext()).ConfigureAwait(false);
LogReply("Advise", adviseReply);
Assert.Equal(ProtocolStatusCode.Ok, adviseReply.ProtocolStatus.Code);
// Wait for an OnDataChange to prove the subscription is live before tearing it down.
MxEvent firstDataChange = await eventWriter
.WaitForMessageAsync(
candidate => candidate.Family == MxEventFamily.OnDataChange
&& candidate.ServerHandle == serverHandle
&& candidate.ItemHandle == itemHandle,
IntegrationTestEnvironment.LiveMxAccessEventTimeout,
streamCancellation.Token)
.ConfigureAwait(false);
LogEvent(firstDataChange);
// RecordingServerStreamWriter.Messages returns a snapshot copy under its own
// lock, so iterating after each teardown step is safe without external sync.
int dataChangeCountBeforeUnadvise = CountMatchingEvents(
eventWriter,
e => e.Family == MxEventFamily.OnDataChange
&& e.ServerHandle == serverHandle
&& e.ItemHandle == itemHandle);
// 1) UnAdvise — must reply Ok; the worker must stop emitting OnDataChange
// for this (server, item) pair after this returns.
MxCommandReply unadviseReply = await fixture.Service.Invoke(
CreateUnAdviseRequest(sessionId, serverHandle, itemHandle),
new TestServerCallContext()).ConfigureAwait(false);
LogReply("UnAdvise", unadviseReply);
Assert.Equal(ProtocolStatusCode.Ok, unadviseReply.ProtocolStatus.Code);
Assert.Equal(MxCommandKind.UnAdvise, unadviseReply.Kind);
// 2) RemoveItem — must reply Ok against the same handles.
MxCommandReply removeItemReply = await fixture.Service.Invoke(
CreateRemoveItemRequest(sessionId, serverHandle, itemHandle),
new TestServerCallContext()).ConfigureAwait(false);
LogReply("RemoveItem", removeItemReply);
Assert.Equal(ProtocolStatusCode.Ok, removeItemReply.ProtocolStatus.Code);
Assert.Equal(MxCommandKind.RemoveItem, removeItemReply.Kind);
// 3) Unregister — closes the client session inside the worker.
MxCommandReply unregisterReply = await fixture.Service.Invoke(
CreateUnregisterRequest(sessionId, serverHandle),
new TestServerCallContext()).ConfigureAwait(false);
LogReply("Unregister", unregisterReply);
Assert.Equal(ProtocolStatusCode.Ok, unregisterReply.ProtocolStatus.Code);
Assert.Equal(MxCommandKind.Unregister, unregisterReply.Kind);
// Allow a short settle window for any in-flight OnDataChange to drain, then
// assert no further events arrived for the un-advised (serverHandle, itemHandle).
// MXAccess parity: after UnAdvise the provider must stop publishing OnDataChange
// for this item — a regression that left a stale subscription alive would surface
// as additional events after this delay.
await Task.Delay(TimeSpan.FromMilliseconds(500)).ConfigureAwait(false);
int dataChangeCountAfterTeardown = CountMatchingEvents(
eventWriter,
e => e.Family == MxEventFamily.OnDataChange
&& e.ServerHandle == serverHandle
&& e.ItemHandle == itemHandle);
output.WriteLine(
$"DataChange count before UnAdvise={dataChangeCountBeforeUnadvise} after teardown+settle={dataChangeCountAfterTeardown}");
Assert.Equal(dataChangeCountBeforeUnadvise, dataChangeCountAfterTeardown);
// A RemoveItem against the just-freed item handle must not silently succeed —
// the worker has to relay MXAccess's invalid-handle response. Closing the
// session is enough for parity, but we sanity-check that re-using the freed
// pair does not accidentally appear Ok.
MxCommandReply secondRemoveItemReply = await fixture.Service.Invoke(
CreateRemoveItemRequest(sessionId, serverHandle, itemHandle),
new TestServerCallContext()).ConfigureAwait(false);
LogReply("RemoveItem(stale)", secondRemoveItemReply);
Assert.NotEqual(ProtocolStatusCode.Ok, secondRemoveItemReply.ProtocolStatus.Code);
}
finally
{
streamCancellation.Cancel();
await ShutDownAsync(fixture, processFactory, sessionId, streamTask).ConfigureAwait(false);
}
}
/// <summary>
/// Verifies the MXAccess <c>WriteSecured</c> path: <c>AuthenticateUser</c> resolves a
/// user id, then <c>WriteSecured</c> against the advised item completes its round-trip
/// to the worker and back. CLAUDE.md singles out <c>WriteSecured</c> ordering as a
/// parity surface the gateway must not "fix" — the test asserts the reply kind and
/// protocol status, not a fabricated outcome.
/// </summary>
[LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_WriteSecured_AuthenticatedRoundTripParity()
{
string workerExecutablePath = IntegrationTestEnvironment.ResolveLiveMxAccessWorkerExecutablePath();
Assert.True(
File.Exists(workerExecutablePath),
$"Live MXAccess worker executable was not found at {workerExecutablePath}. Build the worker or set {IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName}.");
TestWorkerProcessFactory processFactory = new(output);
await using GatewayServiceFixture fixture = new(workerExecutablePath, processFactory, output);
// Stream events so a regression that emitted an OperationComplete or
// OnWriteComplete with wrong handles would still be observable via the test
// output (we don't assert a specific event here — the docs note successful
// writes raise only OnWriteComplete, but WriteSecured against an unprotected
// item commonly fails with 0x80004021 in this provider, which raises no event).
using RecordingServerStreamWriter<MxEvent> eventWriter = new();
string? sessionId = null;
Task? streamTask = null;
using CancellationTokenSource streamCancellation = new();
try
{
OpenSessionReply openReply = await fixture.Service.OpenSession(
new OpenSessionRequest
{
ClientSessionName = "live-mxaccess-write-secured",
ClientCorrelationId = "live-open-write-secured",
CommandTimeout = Duration.FromTimeSpan(CommandTimeout),
},
new TestServerCallContext()).ConfigureAwait(false);
sessionId = openReply.SessionId;
Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code);
streamTask = fixture.Service.StreamEvents(
new StreamEventsRequest { SessionId = sessionId },
eventWriter,
new TestServerCallContext(streamCancellation.Token));
MxCommandReply registerReply = await fixture.Service.Invoke(
CreateRegisterRequest(sessionId),
new TestServerCallContext()).ConfigureAwait(false);
LogReply("Register", registerReply);
Assert.Equal(ProtocolStatusCode.Ok, registerReply.ProtocolStatus.Code);
int serverHandle = registerReply.Register.ServerHandle;
MxCommandReply addItemReply = await fixture.Service.Invoke(
CreateAddItemRequest(sessionId, serverHandle),
new TestServerCallContext()).ConfigureAwait(false);
LogReply("AddItem", addItemReply);
Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code);
int itemHandle = addItemReply.AddItem.ItemHandle;
MxCommandReply adviseReply = await fixture.Service.Invoke(
CreateAdviseRequest(sessionId, serverHandle, itemHandle),
new TestServerCallContext()).ConfigureAwait(false);
LogReply("Advise", adviseReply);
Assert.Equal(ProtocolStatusCode.Ok, adviseReply.ProtocolStatus.Code);
// AuthenticateUser resolves an ArchestrA user id for the WriteSecured call.
// Credentials are env-overridable so the test honors the gateway's "do not
// log secrets" rule and works against either MXAccess's own user store or
// the LmxOpcUa-baseline GLAuth-bridged ArchestrA identity (admin/admin123).
(string verifyUser, string verifyPassword) = ResolveLiveMxAccessSecuredCredentials();
MxCommandReply authReply = await fixture.Service.Invoke(
CreateAuthenticateUserRequest(sessionId, serverHandle, verifyUser, verifyPassword),
new TestServerCallContext()).ConfigureAwait(false);
output.WriteLine(
$"AuthenticateUser status={authReply.ProtocolStatus.Code} hresult={authReply.Hresult} user_id={authReply.AuthenticateUser?.UserId}");
// AuthenticateUser is allowed to fail (the underlying provider may reject
// the credential pair); we use the returned user id if non-zero and fall
// back to 0 ("operator only" / no verifier) so the parity assertion holds.
int currentUserId = authReply.ProtocolStatus.Code == ProtocolStatusCode.Ok
&& authReply.AuthenticateUser is not null
&& authReply.AuthenticateUser.UserId != 0
? authReply.AuthenticateUser.UserId
: 0;
MxCommandReply writeSecuredReply = await fixture.Service.Invoke(
CreateWriteSecuredRequest(
sessionId,
serverHandle,
itemHandle,
currentUserId,
verifierUserId: 0),
new TestServerCallContext()).ConfigureAwait(false);
LogReply("WriteSecured", writeSecuredReply);
// Parity: the command itself completed its round-trip — the reply kind is
// WriteSecured and the gateway protocol status is set. The MXAccess outcome
// (Ok for an unprotected provider, MxaccessFailure with hresult 0x80004021
// when the item is not WriteSecured-eligible) lives in protocol_status +
// hresult, never as a transport fault. The diagnostic message must never
// contain the credential.
Assert.Equal(MxCommandKind.WriteSecured, writeSecuredReply.Kind);
Assert.True(
writeSecuredReply.ProtocolStatus.Code is ProtocolStatusCode.Ok
or ProtocolStatusCode.MxaccessFailure,
$"Unexpected WriteSecured protocol status {writeSecuredReply.ProtocolStatus.Code}.");
Assert.DoesNotContain(verifyPassword, writeSecuredReply.DiagnosticMessage ?? string.Empty, StringComparison.Ordinal);
}
finally
{
streamCancellation.Cancel();
await ShutDownAsync(fixture, processFactory, sessionId, streamTask).ConfigureAwait(false);
}
}
/// <summary>
/// Verifies that killing the worker process marks the session
/// <see cref="SessionState.Faulted"/> with a clean fault classification — the gateway
/// must observe the abnormal exit, transition the session, and surface a non-empty
/// fault description rather than hanging or crashing.
/// </summary>
[LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_AbnormalWorkerExit_MarksSessionFaulted()
{
string workerExecutablePath = IntegrationTestEnvironment.ResolveLiveMxAccessWorkerExecutablePath();
Assert.True(
File.Exists(workerExecutablePath),
$"Live MXAccess worker executable was not found at {workerExecutablePath}. Build the worker or set {IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName}.");
TestWorkerProcessFactory processFactory = new(output);
await using GatewayServiceFixture fixture = new(workerExecutablePath, processFactory, output);
using RecordingServerStreamWriter<MxEvent> eventWriter = new();
string? sessionId = null;
Task? streamTask = null;
using CancellationTokenSource streamCancellation = new();
try
{
OpenSessionReply openReply = await fixture.Service.OpenSession(
new OpenSessionRequest
{
ClientSessionName = "live-mxaccess-abnormal-exit",
ClientCorrelationId = "live-open-abnormal",
CommandTimeout = Duration.FromTimeSpan(CommandTimeout),
},
new TestServerCallContext()).ConfigureAwait(false);
sessionId = openReply.SessionId;
Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code);
streamTask = fixture.Service.StreamEvents(
new StreamEventsRequest { SessionId = sessionId },
eventWriter,
new TestServerCallContext(streamCancellation.Token));
// Kill the worker process directly. WorkerClient's read loop hits an
// end-of-stream on the named pipe and routes through SetFaulted; the
// session manager then marks the session Faulted. We avoid CloseSession
// so the transition is driven by the abnormal exit, not a graceful path.
processFactory.KillAllAndDetach();
DateTimeOffset waitDeadline = DateTimeOffset.UtcNow + StreamShutdownTimeout;
SessionState observedState = SessionState.Unspecified;
string? observedFault = null;
while (DateTimeOffset.UtcNow < waitDeadline)
{
if (fixture.TryGetSession(sessionId, out GatewaySession? session))
{
observedState = session.State;
observedFault = session.FinalFault;
if (observedState == SessionState.Faulted)
{
break;
}
}
await Task.Delay(TimeSpan.FromMilliseconds(50)).ConfigureAwait(false);
}
output.WriteLine($"AbnormalExit observed_state={observedState} fault={observedFault}");
Assert.Equal(SessionState.Faulted, observedState);
Assert.False(string.IsNullOrWhiteSpace(observedFault), "Faulted session must carry a non-empty fault description.");
// The fault classification must come from a known worker-client error code so
// operators get an actionable cause string rather than an opaque exception
// trace. We accept any of the abnormal-exit classifications WorkerClient
// routes through SetFaulted on a killed worker.
Assert.True(
observedFault!.Contains("disconnect", StringComparison.OrdinalIgnoreCase)
|| observedFault.Contains("pipe", StringComparison.OrdinalIgnoreCase)
|| observedFault.Contains("heartbeat", StringComparison.OrdinalIgnoreCase)
|| observedFault.Contains("worker", StringComparison.OrdinalIgnoreCase)
|| observedFault.Contains("end of stream", StringComparison.OrdinalIgnoreCase),
$"Fault description '{observedFault}' did not match a known worker-exit classification.");
}
finally
{
streamCancellation.Cancel();
// sessionId is intentionally null here — the session is already faulted and a
// CloseSession round-trip would just log a cleanup failure. We still wait for
// the worker process exit so the next test starts with a clean state.
await ShutDownAsync(fixture, processFactory, sessionId: null, streamTask).ConfigureAwait(false);
}
}
/// <summary>
/// Closes the session and drains the event stream / worker processes without letting a
/// cleanup timeout mask the original failure from the test body.
/// </summary>
/// <param name="propagateStreamFaults">
/// When <see langword="true"/>, a faulted <paramref name="streamTask"/> is rethrown so the
/// test fails on a silent stream-task exception (the Write parity test relies on this so
/// stream-side defects in event delivery are visible). When <see langword="false"/>, all
/// cleanup exceptions are logged and swallowed so a real test-body assertion failure is not
/// masked by a shutdown timeout (the original IntegrationTests-004 fix).
/// </param>
private async Task ShutDownAsync(
GatewayServiceFixture fixture,
TestWorkerProcessFactory processFactory,
string? sessionId,
Task? streamTask)
Task? streamTask,
bool propagateStreamFaults = false)
{
Exception? streamFault = null;
try
{
if (!string.IsNullOrWhiteSpace(sessionId))
{
await CloseSessionAsync(fixture, sessionId).ConfigureAwait(false);
}
if (streamTask is not null)
{
await streamTask.WaitAsync(StreamShutdownTimeout).ConfigureAwait(false);
}
}
catch (Exception ex)
{
// Cleanup runs in a finally block. A TimeoutException (or a faulted
// StreamEvents task) here would otherwise replace any assertion
// failure raised in the try block. Log it and let the original
// failure surface.
output.WriteLine($"Cleanup error during session/stream shutdown: {ex}");
output.WriteLine($"Cleanup error during session close: {ex}");
}
if (streamTask is not null)
{
try
{
await streamTask.WaitAsync(StreamShutdownTimeout).ConfigureAwait(false);
}
catch (OperationCanceledException ex)
{
// A linked CancellationToken on the streaming TestServerCallContext is the
// intended way to stop StreamEvents promptly — treat the resulting
// OperationCanceledException as a clean shutdown, not a fault.
output.WriteLine($"Event stream task cancelled during shutdown: {ex.Message}");
}
catch (Exception ex)
{
// Cleanup runs in a finally block. By default a faulted StreamEvents task is
// logged and swallowed so a test-body assertion failure is not masked. When
// the caller opts into propagateStreamFaults (the Write parity test), we
// rethrow the fault after the worker-process wait so a silent stream-side
// defect actually fails the test.
output.WriteLine($"Event stream task faulted during shutdown: {ex}");
if (propagateStreamFaults)
{
streamFault = ex;
}
}
}
try
@@ -287,6 +699,11 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
{
output.WriteLine($"Cleanup error while waiting for worker processes to exit: {ex}");
}
if (streamFault is not null)
{
throw streamFault;
}
}
private static MxCommandRequest CreateRegisterRequest(string sessionId)
@@ -373,6 +790,145 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
};
}
private static MxCommandRequest CreateUnAdviseRequest(
string sessionId,
int serverHandle,
int itemHandle)
{
return new MxCommandRequest
{
SessionId = sessionId,
ClientCorrelationId = "live-unadvise",
Command = new MxCommand
{
Kind = MxCommandKind.UnAdvise,
UnAdvise = new UnAdviseCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
};
}
private static MxCommandRequest CreateRemoveItemRequest(
string sessionId,
int serverHandle,
int itemHandle)
{
return new MxCommandRequest
{
SessionId = sessionId,
ClientCorrelationId = "live-remove-item",
Command = new MxCommand
{
Kind = MxCommandKind.RemoveItem,
RemoveItem = new RemoveItemCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
};
}
private static MxCommandRequest CreateUnregisterRequest(
string sessionId,
int serverHandle)
{
return new MxCommandRequest
{
SessionId = sessionId,
ClientCorrelationId = "live-unregister",
Command = new MxCommand
{
Kind = MxCommandKind.Unregister,
Unregister = new UnregisterCommand
{
ServerHandle = serverHandle,
},
},
};
}
private static MxCommandRequest CreateAuthenticateUserRequest(
string sessionId,
int serverHandle,
string verifyUser,
string verifyPassword)
{
return new MxCommandRequest
{
SessionId = sessionId,
ClientCorrelationId = "live-authenticate-user",
Command = new MxCommand
{
Kind = MxCommandKind.AuthenticateUser,
AuthenticateUser = new AuthenticateUserCommand
{
ServerHandle = serverHandle,
VerifyUser = verifyUser,
VerifyUserPassword = verifyPassword,
},
},
};
}
private static MxCommandRequest CreateWriteSecuredRequest(
string sessionId,
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId)
{
return new MxCommandRequest
{
SessionId = sessionId,
ClientCorrelationId = "live-write-secured",
Command = new MxCommand
{
Kind = MxCommandKind.WriteSecured,
WriteSecured = new WriteSecuredCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
CurrentUserId = currentUserId,
VerifierUserId = verifierUserId,
Value = new MxValue
{
DataType = MxDataType.Integer,
Int32Value = 2,
},
},
},
};
}
private static (string VerifyUser, string VerifyPassword) ResolveLiveMxAccessSecuredCredentials()
{
string verifyUser = Environment.GetEnvironmentVariable("MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_USER")
?? "admin";
string verifyPassword = Environment.GetEnvironmentVariable("MXGATEWAY_LIVE_MXACCESS_WRITE_SECURED_PASSWORD")
?? "admin123";
return (verifyUser, verifyPassword);
}
private static int CountMatchingEvents(
RecordingServerStreamWriter<MxEvent> writer,
Func<MxEvent, bool> predicate)
{
int count = 0;
foreach (MxEvent message in writer.Messages)
{
if (predicate(message))
{
count++;
}
}
return count;
}
private async Task CloseSessionAsync(
GatewayServiceFixture fixture,
string sessionId)
@@ -472,6 +1028,17 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// </summary>
public MxAccessGatewayService Service { get; }
/// <summary>
/// Looks up a session by id directly against the in-process registry. The abnormal
/// worker-exit test needs to observe the session's State / FinalFault as the gateway
/// transitions it to Faulted, which the public gRPC API only exposes indirectly via
/// CloseSession's reply (and not before a graceful close completes).
/// </summary>
public bool TryGetSession(string sessionId, out GatewaySession session)
{
return _registry.TryGet(sessionId, out session);
}
/// <summary>
/// Disposes the fixture resources and closes all sessions.
/// </summary>
@@ -516,7 +1083,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
/// <summary>
/// Gathers messages written to a server stream for test inspection.
/// </summary>
private sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T>
private sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T>, IDisposable
{
private readonly object syncRoot = new();
private readonly List<T> messages = [];
@@ -606,6 +1173,16 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
}
}
}
/// <summary>
/// Releases the wait handle backing <c>messageArrived</c>. The writer owns an
/// <see cref="IDisposable"/> field so it must be disposable itself; the leak
/// is otherwise bounded only by how many opt-in live tests run.
/// </summary>
public void Dispose()
{
messageArrived.Dispose();
}
}
/// <summary>
@@ -734,6 +1311,32 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
}
}
/// <summary>
/// Kills every recorded worker process tree so the abnormal-exit test can simulate a
/// crashed worker without going through the graceful shutdown handshake. Failures to
/// kill an already-dead process are tolerated.
/// </summary>
public void KillAllAndDetach()
{
foreach (TestWorkerProcess process in processes)
{
if (process.HasExited)
{
continue;
}
try
{
process.Kill(entireProcessTree: true);
output.WriteLine($"WorkerProcess killed pid={process.Id} (abnormal-exit simulation)");
}
catch (InvalidOperationException ex)
{
output.WriteLine($"WorkerProcess kill skipped pid={process.Id}: {ex.Message}");
}
}
}
private void WriteWorkerOutput(
string streamName,
string? line)
@@ -1,5 +1,4 @@
@page "/apikeys"
@page "/dashboard/apikeys"
@inherits DashboardPageBase
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject IDashboardApiKeyManagementService ApiKeyManagementService
@@ -1,5 +1,4 @@
@page "/"
@page "/dashboard/"
@inherits DashboardPageBase
<PageTitle>MXAccess Gateway Dashboard</PageTitle>
@@ -1,5 +1,4 @@
@page "/events"
@page "/dashboard/events"
@inherits DashboardPageBase
<PageTitle>Dashboard Events</PageTitle>
@@ -1,5 +1,4 @@
@page "/galaxy"
@page "/dashboard/galaxy"
@inherits DashboardPageBase
<PageTitle>Dashboard Galaxy</PageTitle>
@@ -1,5 +1,4 @@
@page "/sessions/{SessionId}"
@page "/dashboard/sessions/{SessionId}"
@inherits DashboardPageBase
<PageTitle>Dashboard Session</PageTitle>
@@ -1,5 +1,4 @@
@page "/sessions"
@page "/dashboard/sessions"
@inherits DashboardPageBase
<PageTitle>Dashboard Sessions</PageTitle>
@@ -1,5 +1,4 @@
@page "/settings"
@page "/dashboard/settings"
@inherits DashboardPageBase
<PageTitle>Dashboard Settings</PageTitle>
@@ -1,5 +1,4 @@
@page "/workers"
@page "/dashboard/workers"
@inherits DashboardPageBase
<PageTitle>Dashboard Workers</PageTitle>
@@ -7,13 +7,42 @@ namespace MxGateway.Server.Galaxy;
public static class GalaxyGlobMatcher
{
/// <summary>
/// Compiled-regex cache keyed by glob pattern. <c>IsMatch</c> is called once per
/// object per <c>DiscoverHierarchy</c>/<c>WatchDeployEvents</c> evaluation, so the
/// same handful of glob patterns are translated repeatedly; caching avoids
/// rebuilding and recompiling the regex on every call.
/// Maximum number of compiled-regex entries retained in <see cref="RegexCache"/>.
/// The cache is keyed by glob pattern and patterns flow in from two sources:
/// admin-controlled API-key constraints (naturally bounded) and the
/// client-supplied <c>DiscoverHierarchyRequest.TagNameGlob</c> (unbounded — a
/// client can iterate through generated names and create millions of distinct
/// globs over the process lifetime). Capping the cache bounds memory while
/// keeping the hot working set hit-cached.
/// </summary>
internal const int RegexCacheCapacity = 256;
/// <summary>
/// Bounded compiled-regex cache keyed by glob pattern. <c>IsMatch</c> is called
/// once per object per <c>DiscoverHierarchy</c>/<c>WatchDeployEvents</c>
/// evaluation, so the same handful of glob patterns are translated
/// repeatedly; caching avoids rebuilding and recompiling the regex on every
/// call. Beyond <see cref="RegexCacheCapacity"/> entries the oldest insertion
/// is evicted so a client cannot grow the cache without bound by submitting
/// unique patterns. Eviction is approximate (FIFO over insertion order, not
/// true LRU) because we only need the bound, not exact recency tracking.
/// </summary>
private static readonly ConcurrentDictionary<string, Regex> RegexCache = new(StringComparer.Ordinal);
/// <summary>
/// Insertion-order queue used to evict the oldest cache entry when the cache
/// exceeds <see cref="RegexCacheCapacity"/>. A separate queue keeps the
/// <see cref="RegexCache"/> reads lock-free; the lock below only guards the
/// eviction path.
/// </summary>
private static readonly ConcurrentQueue<string> InsertionOrder = new();
private static readonly object EvictionLock = new();
/// <summary>
/// Current cache size, exposed for tests asserting the cap is honoured.
/// </summary>
internal static int CurrentCacheSize => RegexCache.Count;
public static bool IsMatch(string value, string glob)
{
if (string.IsNullOrWhiteSpace(glob))
@@ -26,10 +55,42 @@ public static class GalaxyGlobMatcher
private static Regex GetOrCreateRegex(string glob)
{
return RegexCache.GetOrAdd(glob, static pattern => new Regex(
BuildRegex(pattern),
if (RegexCache.TryGetValue(glob, out Regex? existing))
{
return existing;
}
Regex compiled = new(
BuildRegex(glob),
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled,
TimeSpan.FromMilliseconds(100)));
TimeSpan.FromMilliseconds(100));
if (RegexCache.TryAdd(glob, compiled))
{
InsertionOrder.Enqueue(glob);
EvictIfOverCapacity();
return compiled;
}
// Another thread won the race — use its compiled regex.
return RegexCache[glob];
}
private static void EvictIfOverCapacity()
{
if (RegexCache.Count <= RegexCacheCapacity)
{
return;
}
// Serialize eviction so two threads do not race past the cap together.
lock (EvictionLock)
{
while (RegexCache.Count > RegexCacheCapacity && InsertionOrder.TryDequeue(out string? oldest))
{
RegexCache.TryRemove(oldest, out _);
}
}
}
private static string BuildRegex(string glob)
@@ -17,7 +17,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
{
private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(5);
private readonly GalaxyRepository _repository;
private readonly IGalaxyRepository _repository;
private readonly IGalaxyDeployNotifier _notifier;
private readonly TimeProvider _timeProvider;
private readonly ILogger<GalaxyHierarchyCache>? _logger;
@@ -31,7 +31,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
/// <param name="timeProvider">Provider for current time; defaults to system time.</param>
/// <param name="logger">Optional logger for diagnostic output.</param>
public GalaxyHierarchyCache(
GalaxyRepository repository,
IGalaxyRepository repository,
IGalaxyDeployNotifier notifier,
TimeProvider? timeProvider = null,
ILogger<GalaxyHierarchyCache>? logger = null)
@@ -8,7 +8,7 @@ namespace MxGateway.Server.Galaxy;
/// consumers — the same SQL drives the OPC UA server's address space and this gateway's
/// gRPC browse surface.
/// </summary>
public sealed class GalaxyRepository(GalaxyRepositoryOptions options)
public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository
{
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
@@ -8,10 +8,17 @@ public sealed class GalaxyRepositoryOptions
{
public const string SectionName = "MxGateway:Galaxy";
/// <summary>The SQL Server connection string for the Galaxy Repository database.</summary>
public string ConnectionString { get; init; } =
/// <summary>
/// Default SQL Server connection string for the Galaxy Repository database.
/// Single source of truth shared with the integration-test fallback so the
/// production default and the live-test default cannot drift.
/// </summary>
public const string DefaultConnectionString =
"Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
/// <summary>The SQL Server connection string for the Galaxy Repository database.</summary>
public string ConnectionString { get; init; } = DefaultConnectionString;
/// <summary>The timeout in seconds for SQL commands executed against the Galaxy Repository.</summary>
public int CommandTimeoutSeconds { get; init; } = 60;
@@ -16,6 +16,7 @@ public static class GalaxyRepositoryServiceCollectionExtensions
services.AddSingleton(sp =>
new GalaxyRepository(sp.GetRequiredService<IOptions<GalaxyRepositoryOptions>>().Value));
services.AddSingleton<IGalaxyRepository>(sp => sp.GetRequiredService<GalaxyRepository>());
services.AddSingleton<IGalaxyDeployNotifier, GalaxyDeployNotifier>();
services.AddSingleton<IGalaxyHierarchyCache, GalaxyHierarchyCache>();
@@ -0,0 +1,30 @@
namespace MxGateway.Server.Galaxy;
/// <summary>
/// Abstraction over <see cref="GalaxyRepository"/> consumed by
/// <see cref="GalaxyHierarchyCache"/>. Exists so the cache can be unit-tested
/// against an in-memory fake that throws a <see cref="System.Exception"/>
/// from <see cref="GetLastDeployTimeAsync"/> (the unavailable-backend code
/// path) without standing up a real <c>Microsoft.Data.SqlClient</c>
/// <c>SqlConnection</c> against a bogus host/port. The production gateway
/// wires the concrete <see cref="GalaxyRepository"/>; the SQL surface itself
/// stays covered by <c>MxGateway.IntegrationTests.Galaxy.GalaxyRepositoryLiveTests</c>.
/// </summary>
public interface IGalaxyRepository
{
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
Task<bool> TestConnectionAsync(CancellationToken ct = default);
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default);
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default);
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default);
}
@@ -18,6 +18,8 @@ public sealed class GatewayGrpcScopeResolver
CloseSessionRequest => GatewayScopes.SessionClose,
StreamEventsRequest => GatewayScopes.EventsRead,
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
AcknowledgeAlarmRequest => GatewayScopes.InvokeWrite,
QueryActiveAlarmsRequest => GatewayScopes.EventsRead,
TestConnectionRequest or
GetLastDeployTimeRequest or
DiscoverHierarchyRequest or
+101 -7
View File
@@ -263,6 +263,17 @@ public sealed class GatewaySession
/// Transitions the session to a new state with constraints for terminal states.
/// </summary>
/// <param name="nextState">Next session state to transition to.</param>
/// <remarks>
/// <see cref="SessionState.Closed"/> is terminal. <see cref="SessionState.Faulted"/>
/// only allows a transition to <see cref="SessionState.Closed"/>.
/// <see cref="SessionState.Closing"/> only allows a transition to
/// <see cref="SessionState.Closed"/> (or <see cref="SessionState.Faulted"/>) — once
/// <see cref="CloseAsync"/> has started, no late lifecycle callback can revive the
/// session by walking it back to <see cref="SessionState.Ready"/> or any earlier
/// state. Both close-related writes (<c>Closing</c> and <c>Closed</c>) go through
/// <c>_syncRoot</c> just like every other state read/write, closing the split-lock
/// race called out in Server-015.
/// </remarks>
public void TransitionTo(SessionState nextState)
{
lock (_syncRoot)
@@ -277,6 +288,13 @@ public sealed class GatewaySession
return;
}
if (_state is SessionState.Closing
&& nextState is not SessionState.Closed
&& nextState is not SessionState.Faulted)
{
return;
}
_state = nextState;
}
}
@@ -717,6 +735,14 @@ public sealed class GatewaySession
/// </summary>
/// <param name="reason">Reason for closing the session.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <remarks>
/// Concurrent close attempts are serialized by <c>_closeLock</c> so only one close
/// runs at a time, but every read/write of <c>_state</c> still passes through
/// <c>_syncRoot</c> (via <see cref="TryBeginClose"/> and <see cref="MarkClosed"/>) —
/// the close path therefore obeys the same lock discipline as
/// <see cref="TransitionTo"/> / <see cref="MarkFaulted"/> and a concurrent
/// <c>TransitionTo(Ready)</c> cannot race past a <c>Closing</c> write.
/// </remarks>
public async Task<SessionCloseResult> CloseAsync(
string reason,
CancellationToken cancellationToken)
@@ -726,15 +752,11 @@ public sealed class GatewaySession
{
try
{
if (_state is SessionState.Closed)
if (!TryBeginClose(out bool alreadyClosing))
{
return new SessionCloseResult(SessionId, SessionState.Closed, AlreadyClosed: true);
}
bool alreadyClosing = _closeStarted;
_closeStarted = true;
_state = SessionState.Closing;
if (_workerClient is not null)
{
try
@@ -758,7 +780,7 @@ public sealed class GatewaySession
}
}
_state = SessionState.Closed;
MarkClosed();
return new SessionCloseResult(SessionId, SessionState.Closed, alreadyClosing);
}
catch (Exception exception) when (exception is not SessionCloseStartedException)
@@ -774,6 +796,40 @@ public sealed class GatewaySession
}
}
// Returns false when the session is already Closed (caller short-circuits with
// AlreadyClosed: true). Otherwise sets _state = Closing under _syncRoot so a
// concurrent TransitionTo(Ready) — which only refuses to overwrite Closed/Faulted
// — cannot flip the session back to Ready after close started. The `alreadyClosing`
// out parameter mirrors the previous `_closeStarted` check so the surface contract
// (a second concurrent close returns AlreadyClosed: alreadyClosing) is preserved.
private bool TryBeginClose(out bool alreadyClosing)
{
lock (_syncRoot)
{
if (_state is SessionState.Closed)
{
alreadyClosing = _closeStarted;
return false;
}
alreadyClosing = _closeStarted;
_closeStarted = true;
_state = SessionState.Closing;
return true;
}
}
// Final terminal transition; under _syncRoot to keep _state writes single-lock.
// Closed is unconditionally terminal — TransitionTo refuses to overwrite it —
// so we don't need to re-check the precondition here.
private void MarkClosed()
{
lock (_syncRoot)
{
_state = SessionState.Closed;
}
}
/// <summary>
/// Terminates the worker process immediately.
/// </summary>
@@ -787,9 +843,47 @@ public sealed class GatewaySession
/// <summary>
/// Disposes the session and frees associated resources.
/// </summary>
/// <remarks>
/// Acquires <c>_closeLock</c> once before disposing so an in-flight
/// <see cref="CloseAsync"/> finishes before the semaphore is released and
/// reclaimed. Without this gate, the in-flight close's <c>_closeLock.Release()</c>
/// would race the dispose and raise <see cref="ObjectDisposedException"/>.
/// The acquire is best-effort: a non-cancellable wait that swallows
/// <see cref="ObjectDisposedException"/> so double-dispose still completes.
/// </remarks>
public async ValueTask DisposeAsync()
{
_closeLock.Dispose();
try
{
// CancellationToken.None — disposal must not be cancelled, and a misbehaving
// close path that never releases would have to be torn down by the worker
// shutdown timeout long before we reach here.
await _closeLock.WaitAsync(CancellationToken.None).ConfigureAwait(false);
try
{
// Hand the slot back so the semaphore's internal counter is consistent
// for any contemporaneous waiter, then dispose. Once disposed, every
// subsequent WaitAsync / Release will throw — but DisposeAsync's contract
// is "no concurrent close after this point", which SessionManager honors.
_closeLock.Release();
}
catch (ObjectDisposedException)
{
}
}
catch (ObjectDisposedException)
{
// Already disposed (e.g. double-dispose); nothing to gate on.
}
try
{
_closeLock.Dispose();
}
catch (ObjectDisposedException)
{
}
if (_workerClient is not null)
{
await _workerClient.DisposeAsync().ConfigureAwait(false);
@@ -6,18 +6,18 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Server.Sessions;
/// <summary>
/// PR A.6 / A.7 — gateway-side dispatcher for the alarm-RPC surface.
/// Bridges the public <c>AcknowledgeAlarm</c> + <c>QueryActiveAlarms</c>
/// gRPC handlers to the worker process that hosts
/// <c>IMxAccessAlarmConsumer</c>.
/// Gateway-side dispatcher seam for the alarm-RPC surface. Bridges the
/// public <c>AcknowledgeAlarm</c> + <c>QueryActiveAlarms</c> gRPC handlers
/// to the worker process that hosts <c>IMxAccessAlarmConsumer</c>.
/// </summary>
/// <remarks>
/// <para>
/// Production implementations live in <c>WorkerAlarmRpcDispatcher</c>
/// (this PR ships a not-yet-wired default that returns a clear
/// worker-pending diagnostic) and route through the existing
/// worker-pipe IPC. Tests inject a fake to exercise the gateway
/// handler shape without spinning up a worker process.
/// DI binds the production <see cref="WorkerAlarmRpcDispatcher"/> by
/// default; it routes calls through the existing worker-pipe IPC.
/// <c>NotWiredAlarmRpcDispatcher</c> is only the null fallback used
/// when no dispatcher is registered (DI omission / standalone tests).
/// Other tests inject a fake to exercise the gateway handler shape
/// without spinning up a worker process.
/// </para>
/// <para>
/// The dispatcher is session-scoped: every call resolves the
@@ -188,7 +188,14 @@ public sealed class WorkerAlarmRpcDispatcher(
if (!sessionRegistry.TryGet(request.SessionId, out GatewaySession session))
{
yield break;
// Server-019: align with AcknowledgeAsync's missing-session handling and
// surface a SessionNotFound error rather than yielding an empty stream.
// QueryActiveAlarms is server-streaming, so a thrown exception is the
// cleaner fit than an in-band ProtocolStatus; MxAccessGatewayService maps
// SessionManagerException(SessionNotFound) to gRPC NotFound.
throw new SessionManagerException(
SessionManagerErrorCode.SessionNotFound,
$"Session '{request.SessionId}' not found.");
}
WorkerCommand workerCommand = new WorkerCommand
@@ -11,7 +11,18 @@ public sealed class GatewayContractInfoTests
Assert.Equal("mxaccess-worker", GatewayContractInfo.DefaultBackendName);
}
/// <summary>Verifies that the gateway protocol version is bumped to three after the alarm proto extension.</summary>
/// <summary>
/// Pins the current <see cref="GatewayContractInfo.GatewayProtocolVersion"/>
/// constant at 3. Both the alarm proto extension (`AcknowledgeAlarm` /
/// `QueryActiveAlarms` RPCs, the `OnAlarmTransitionEvent` body, and the
/// alarm command/reply payload cases) and the bulk write/read command
/// family extension (`WriteBulk` / `Write2Bulk` / `WriteSecuredBulk` /
/// `WriteSecured2Bulk` / `ReadBulk` plus their `BulkWriteReply` and
/// `BulkReadReply` payloads) shipped under version 3 — both were strictly
/// additive contract changes, so neither required a further bump. A
/// future breaking contract change should bump this constant and update
/// this test in lock-step.
/// </summary>
[Fact]
public void GatewayProtocolVersion_IsVersionThree()
{
@@ -770,4 +770,463 @@ public sealed class ProtobufContractRoundTripTests
Assert.Equal(lastDeploy, GetLastDeployTimeReply.Parser.ParseFrom(lastDeploy.ToByteArray()));
Assert.Equal(testConnection, TestConnectionReply.Parser.ParseFrom(testConnection.ToByteArray()));
}
/// <summary>
/// Verifies that a <see cref="WriteBulkCommand"/> carrying multiple
/// <see cref="WriteBulkEntry"/> items round-trips, including the
/// per-entry <c>value</c> and <c>user_id</c> fields.
/// </summary>
[Fact]
public void WriteBulkCommand_RoundTripsEntries()
{
var original = new MxCommand
{
Kind = MxCommandKind.WriteBulk,
WriteBulk = new WriteBulkCommand
{
ServerHandle = 10,
Entries =
{
new WriteBulkEntry
{
ItemHandle = 21,
UserId = 7,
Value = new MxValue
{
DataType = MxDataType.Float,
FloatValue = 1.25f,
VariantType = "VT_R4",
},
},
new WriteBulkEntry
{
ItemHandle = 22,
Value = new MxValue
{
DataType = MxDataType.Integer,
Int32Value = 42,
VariantType = "VT_I4",
},
},
},
},
};
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxCommand.PayloadOneofCase.WriteBulk, parsed.PayloadCase);
Assert.Equal(2, parsed.WriteBulk.Entries.Count);
Assert.Equal(7, parsed.WriteBulk.Entries[0].UserId);
}
/// <summary>
/// Verifies that a <see cref="Write2BulkCommand"/> round-trips, including
/// the per-entry <c>timestamp_value</c> field that distinguishes Write2
/// from Write.
/// </summary>
[Fact]
public void Write2BulkCommand_RoundTripsEntriesWithTimestampValue()
{
var timestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 19, 11, 0, 0, DateTimeKind.Utc));
var original = new MxCommand
{
Kind = MxCommandKind.Write2Bulk,
Write2Bulk = new Write2BulkCommand
{
ServerHandle = 10,
Entries =
{
new Write2BulkEntry
{
ItemHandle = 21,
UserId = 7,
Value = new MxValue
{
DataType = MxDataType.Float,
FloatValue = 99.9f,
VariantType = "VT_R4",
},
TimestampValue = new MxValue
{
DataType = MxDataType.Time,
TimestampValue = timestamp,
VariantType = "VT_DATE",
},
},
},
},
};
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxCommand.PayloadOneofCase.Write2Bulk, parsed.PayloadCase);
Assert.NotNull(parsed.Write2Bulk.Entries[0].TimestampValue);
}
/// <summary>
/// Verifies that a <see cref="WriteSecuredBulkCommand"/> round-trips,
/// pinning the credential-bearing entry shape
/// (<c>current_user_id</c>, <c>verifier_user_id</c>, <c>value</c>).
/// See Contracts-011 for the credential-sensitivity comment on
/// <c>WriteSecuredBulkEntry.value</c>.
/// </summary>
[Fact]
public void WriteSecuredBulkCommand_RoundTripsCredentialBearingEntries()
{
var original = new MxCommand
{
Kind = MxCommandKind.WriteSecuredBulk,
WriteSecuredBulk = new WriteSecuredBulkCommand
{
ServerHandle = 10,
Entries =
{
new WriteSecuredBulkEntry
{
ItemHandle = 21,
CurrentUserId = 100,
VerifierUserId = 200,
Value = new MxValue
{
DataType = MxDataType.Float,
FloatValue = 75.0f,
VariantType = "VT_R4",
},
},
},
},
};
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxCommand.PayloadOneofCase.WriteSecuredBulk, parsed.PayloadCase);
Assert.Equal(100, parsed.WriteSecuredBulk.Entries[0].CurrentUserId);
Assert.Equal(200, parsed.WriteSecuredBulk.Entries[0].VerifierUserId);
}
/// <summary>
/// Verifies that a <see cref="WriteSecured2BulkCommand"/> round-trips,
/// including both the credential-sensitive <c>value</c> and the
/// <c>timestamp_value</c> per entry.
/// </summary>
[Fact]
public void WriteSecured2BulkCommand_RoundTripsCredentialBearingEntriesWithTimestamp()
{
var timestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 19, 11, 30, 0, DateTimeKind.Utc));
var original = new MxCommand
{
Kind = MxCommandKind.WriteSecured2Bulk,
WriteSecured2Bulk = new WriteSecured2BulkCommand
{
ServerHandle = 10,
Entries =
{
new WriteSecured2BulkEntry
{
ItemHandle = 21,
CurrentUserId = 100,
VerifierUserId = 200,
Value = new MxValue
{
DataType = MxDataType.Float,
FloatValue = 50.0f,
VariantType = "VT_R4",
},
TimestampValue = new MxValue
{
DataType = MxDataType.Time,
TimestampValue = timestamp,
VariantType = "VT_DATE",
},
},
},
},
};
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxCommand.PayloadOneofCase.WriteSecured2Bulk, parsed.PayloadCase);
Assert.NotNull(parsed.WriteSecured2Bulk.Entries[0].TimestampValue);
}
/// <summary>
/// Verifies that a <see cref="ReadBulkCommand"/> round-trips, including
/// the <c>tag_addresses</c> list and the <c>timeout_ms</c> field that
/// distinguishes the cached-vs-snapshot lifecycle.
/// </summary>
[Fact]
public void ReadBulkCommand_RoundTripsTagAddressesAndTimeout()
{
var original = new MxCommand
{
Kind = MxCommandKind.ReadBulk,
ReadBulk = new ReadBulkCommand
{
ServerHandle = 10,
TagAddresses = { "Provider!Tank01.Level", "Provider!Tank02.Level" },
TimeoutMs = 2500,
},
};
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxCommand.PayloadOneofCase.ReadBulk, parsed.PayloadCase);
Assert.Equal(2, parsed.ReadBulk.TagAddresses.Count);
Assert.Equal(2500u, parsed.ReadBulk.TimeoutMs);
}
/// <summary>
/// Verifies that a <see cref="BulkWriteReply"/> carrying mixed-outcome
/// <see cref="BulkWriteResult"/> entries round-trips and that the
/// proto3 <c>optional int32 hresult</c> presence flag survives both the
/// "hresult set" and "hresult unset" cases.
/// </summary>
[Fact]
public void BulkWriteReply_RoundTripsResultsWithOptionalHresultPresence()
{
var original = new BulkWriteReply
{
Results =
{
new BulkWriteResult
{
ServerHandle = 10,
ItemHandle = 21,
WasSuccessful = true,
Hresult = 0,
Statuses =
{
new MxStatusProxy
{
Success = 1,
Category = MxStatusCategory.Ok,
DetectedBy = MxStatusSource.RespondingLmx,
},
},
},
new BulkWriteResult
{
ServerHandle = 10,
ItemHandle = 22,
WasSuccessful = false,
Hresult = unchecked((int)0x80004005),
ErrorMessage = "item not advised",
},
new BulkWriteResult
{
ServerHandle = 10,
ItemHandle = 23,
WasSuccessful = false,
// Hresult deliberately UNSET — exercises the proto3
// `optional int32` HasField() = false arm.
ErrorMessage = "tag rejected by allowlist",
},
},
};
var parsed = BulkWriteReply.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(3, parsed.Results.Count);
Assert.True(parsed.Results[0].HasHresult);
Assert.True(parsed.Results[1].HasHresult);
Assert.False(parsed.Results[2].HasHresult);
Assert.True(parsed.Results[0].WasSuccessful);
Assert.False(parsed.Results[2].WasSuccessful);
Assert.Single(parsed.Results[0].Statuses);
}
/// <summary>
/// Verifies that a <see cref="BulkReadReply"/> carrying both cached
/// (<c>was_cached = true</c>) and uncached (<c>was_cached = false</c>)
/// <see cref="BulkReadResult"/> entries round-trips. Pins the
/// deliberate absence of <c>hresult</c> on <see cref="BulkReadResult"/>
/// — failures are carried as <c>was_successful = false</c> plus
/// <c>error_message</c> only.
/// </summary>
[Fact]
public void BulkReadReply_RoundTripsCachedAndSnapshotResults()
{
var sourceTimestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc));
var original = new BulkReadReply
{
Results =
{
new BulkReadResult
{
ServerHandle = 10,
TagAddress = "Provider!Tank01.Level",
ItemHandle = 21,
WasSuccessful = true,
WasCached = true,
Value = new MxValue
{
DataType = MxDataType.Float,
FloatValue = 42.5f,
VariantType = "VT_R4",
},
Quality = 192,
SourceTimestamp = sourceTimestamp,
Statuses =
{
new MxStatusProxy
{
Success = 1,
Category = MxStatusCategory.Ok,
DetectedBy = MxStatusSource.RespondingNmx,
},
},
},
new BulkReadResult
{
ServerHandle = 10,
TagAddress = "Provider!Tank02.Level",
ItemHandle = 22,
WasSuccessful = true,
WasCached = false,
Value = new MxValue
{
DataType = MxDataType.Integer,
Int32Value = 0,
VariantType = "VT_I4",
},
SourceTimestamp = sourceTimestamp,
},
new BulkReadResult
{
ServerHandle = 10,
TagAddress = "Provider!Bad.Tag",
WasSuccessful = false,
WasCached = false,
ErrorMessage = "snapshot timed out before first OnDataChange",
},
},
};
var parsed = BulkReadReply.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(3, parsed.Results.Count);
Assert.True(parsed.Results[0].WasCached);
Assert.False(parsed.Results[1].WasCached);
Assert.False(parsed.Results[2].WasSuccessful);
Assert.Equal("snapshot timed out before first OnDataChange", parsed.Results[2].ErrorMessage);
// BulkReadResult has no `hresult` field — pin that contract.
Assert.DoesNotContain(
BulkReadResult.Descriptor.Fields.InDeclarationOrder(),
field => field.Name == "hresult");
}
/// <summary>
/// Verifies that an <see cref="MxCommandReply"/> with each of the new
/// bulk write/read payload oneof cases round-trips and that
/// <see cref="MxCommandReply.PayloadOneofCase"/> resolves to the
/// expected value. Pins every new oneof case added by the bulk
/// write/read extension.
/// </summary>
[Theory]
[InlineData(MxCommandKind.WriteBulk, MxCommandReply.PayloadOneofCase.WriteBulk)]
[InlineData(MxCommandKind.Write2Bulk, MxCommandReply.PayloadOneofCase.Write2Bulk)]
[InlineData(MxCommandKind.WriteSecuredBulk, MxCommandReply.PayloadOneofCase.WriteSecuredBulk)]
[InlineData(MxCommandKind.WriteSecured2Bulk, MxCommandReply.PayloadOneofCase.WriteSecured2Bulk)]
public void MxCommandReply_RoundTripsBulkWritePayloadCases(
MxCommandKind kind,
MxCommandReply.PayloadOneofCase expectedPayloadCase)
{
var reply = new BulkWriteReply
{
Results =
{
new BulkWriteResult
{
ServerHandle = 5,
ItemHandle = 7,
WasSuccessful = true,
Hresult = 0,
},
},
};
var original = new MxCommandReply
{
SessionId = "session-1",
CorrelationId = "gateway-correlation-bulk-write",
Kind = kind,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Hresult = 0,
};
switch (expectedPayloadCase)
{
case MxCommandReply.PayloadOneofCase.WriteBulk:
original.WriteBulk = reply;
break;
case MxCommandReply.PayloadOneofCase.Write2Bulk:
original.Write2Bulk = reply;
break;
case MxCommandReply.PayloadOneofCase.WriteSecuredBulk:
original.WriteSecuredBulk = reply;
break;
case MxCommandReply.PayloadOneofCase.WriteSecured2Bulk:
original.WriteSecured2Bulk = reply;
break;
default:
throw new System.ArgumentOutOfRangeException(nameof(expectedPayloadCase));
}
var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(expectedPayloadCase, parsed.PayloadCase);
Assert.Equal(kind, parsed.Kind);
}
/// <summary>
/// Verifies that an <see cref="MxCommandReply"/> with kind
/// <see cref="MxCommandKind.ReadBulk"/> and a populated
/// <see cref="BulkReadReply"/> payload round-trips and resolves to
/// <see cref="MxCommandReply.PayloadOneofCase.ReadBulk"/>.
/// </summary>
[Fact]
public void MxCommandReply_RoundTripsReadBulkPayload()
{
var original = new MxCommandReply
{
SessionId = "session-1",
CorrelationId = "gateway-correlation-read-bulk",
Kind = MxCommandKind.ReadBulk,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Hresult = 0,
ReadBulk = new BulkReadReply
{
Results =
{
new BulkReadResult
{
ServerHandle = 5,
TagAddress = "Provider!Tank01.Level",
ItemHandle = 7,
WasSuccessful = true,
WasCached = true,
Value = new MxValue
{
DataType = MxDataType.Float,
FloatValue = 12.5f,
VariantType = "VT_R4",
},
},
},
},
};
var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxCommandReply.PayloadOneofCase.ReadBulk, parsed.PayloadCase);
Assert.Single(parsed.ReadBulk.Results);
Assert.True(parsed.ReadBulk.Results[0].WasCached);
}
}
@@ -110,6 +110,29 @@ public sealed class GalaxyFilterInputSafetyTests
}
}
/// <summary>
/// Regression guard for finding Server-018: <see cref="GalaxyGlobMatcher"/>'s
/// internal compiled-regex cache must stay bounded so a client cannot grow it
/// without limit by submitting unique <c>TagNameGlob</c> values over the
/// process lifetime. Feeding the matcher far more distinct globs than the cap
/// must leave <c>CurrentCacheSize</c> at or below <c>RegexCacheCapacity</c>.
/// </summary>
[Fact]
public void GlobMatcher_WithManyDistinctPatterns_CacheStaysBounded()
{
// Submit well past the cap from a single thread to exercise the eviction path
// deterministically. The cap is internal; assert on it directly so the test
// tracks the source of truth.
int submissions = GalaxyGlobMatcher.RegexCacheCapacity * 4;
for (int i = 0; i < submissions; i++)
{
string uniqueGlob = $"client_supplied_{i}_*";
GalaxyGlobMatcher.IsMatch($"client_supplied_{i}_thing", uniqueGlob);
}
Assert.InRange(GalaxyGlobMatcher.CurrentCacheSize, 0, GalaxyGlobMatcher.RegexCacheCapacity);
}
/// <summary>
/// Verifies a pathological glob does not cause catastrophic regex backtracking —
/// <see cref="GalaxyGlobMatcher"/> escapes every literal character and applies a
@@ -12,7 +12,8 @@ public sealed class GalaxyHierarchyCacheTests
public void Current_BeforeAnyRefresh_ReturnsEmpty()
{
GalaxyDeployNotifier notifier = new();
GalaxyHierarchyCache cache = CreateCache(notifier, new ManualTimeProvider());
ThrowingGalaxyRepository repository = new(new InvalidOperationException("not invoked"));
GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider());
GalaxyHierarchyCacheEntry entry = cache.Current;
@@ -23,21 +24,28 @@ public sealed class GalaxyHierarchyCacheTests
}
/// <summary>
/// Verifies cache marks unavailable and does not publish when SQL is unreachable.
/// Verifies cache marks unavailable and does not publish when the repository
/// surface throws — the production trigger for this code path is a SQL
/// connection failure, but it is fully covered by the cache's exception
/// branch and does not require a real TCP probe from a unit test.
/// </summary>
[Fact]
public async Task RefreshAsync_WhenSqlIsUnreachable_MarksUnavailableAndDoesNotPublish()
public async Task RefreshAsync_WhenRepositoryThrows_MarksUnavailableAndDoesNotPublish()
{
GalaxyDeployNotifier notifier = new();
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-28T12:00:00Z"));
GalaxyHierarchyCache cache = CreateCache(notifier, clock);
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-28T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture));
ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable"));
GalaxyHierarchyCache cache = new(repository, notifier, clock);
await cache.RefreshAsync(CancellationToken.None);
Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status);
Assert.False(string.IsNullOrWhiteSpace(cache.Current.LastError));
Assert.Equal("Galaxy repository unreachable", cache.Current.LastError);
Assert.Null(notifier.Latest);
Assert.True(cache.WaitForFirstLoadAsync(CancellationToken.None).IsCompletedSuccessfully);
Assert.Equal(1, repository.GetLastDeployTimeCount);
Assert.Equal(0, repository.GetHierarchyCount);
Assert.Equal(0, repository.GetAttributesCount);
}
/// <summary>
@@ -112,15 +120,40 @@ public sealed class GalaxyHierarchyCacheTests
Assert.Same(root, index.ObjectViewsById[1].Object);
}
private static GalaxyHierarchyCache CreateCache(GalaxyDeployNotifier notifier, TimeProvider clock)
private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository
{
GalaxyRepositoryOptions options = new()
/// <summary>Gets the number of times <see cref="GetLastDeployTimeAsync"/> was called.</summary>
public int GetLastDeployTimeCount { get; private set; }
/// <summary>Gets the number of times <see cref="GetHierarchyAsync"/> was called.</summary>
public int GetHierarchyCount { get; private set; }
/// <summary>Gets the number of times <see cref="GetAttributesAsync"/> was called.</summary>
public int GetAttributesCount { get; private set; }
/// <inheritdoc />
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false);
/// <inheritdoc />
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
{
ConnectionString = "Server=127.0.0.1,65500;Database=ZB;Connection Timeout=1;Encrypt=False;",
CommandTimeoutSeconds = 1,
};
MxGateway.Server.Galaxy.GalaxyRepository repository = new(options);
return new GalaxyHierarchyCache(repository, notifier, clock);
GetLastDeployTimeCount++;
throw toThrow;
}
/// <inheritdoc />
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
{
GetHierarchyCount++;
throw toThrow;
}
/// <inheritdoc />
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
{
GetAttributesCount++;
throw toThrow;
}
}
private sealed class ManualTimeProvider(DateTimeOffset start = default) : TimeProvider
@@ -12,9 +12,9 @@ public sealed class DashboardCookieOptionsTests
{
/// <summary>Verifies that the application configures secure dashboard authentication cookies.</summary>
[Fact]
public void Build_ConfiguresSecureDashboardCookie()
public async Task Build_ConfiguresSecureDashboardCookie()
{
WebApplication app = GatewayApplication.Build([]);
await using WebApplication app = GatewayApplication.Build([]);
IOptionsMonitor<CookieAuthenticationOptions> optionsMonitor = app.Services
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
@@ -1,3 +1,4 @@
using System.Globalization;
using Microsoft.Extensions.Options;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Configuration;
@@ -42,19 +43,19 @@ public sealed class DashboardSnapshotServiceTests
GatewaySession activeSession = CreateSession(
"session-active",
"client-one",
DateTimeOffset.Parse("2026-04-26T10:00:00Z"));
DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture));
activeSession.AttachWorkerClient(new FakeWorkerClient("session-active", 1201, WorkerClientState.Ready));
activeSession.MarkReady();
GatewaySession faultedSession = CreateSession(
"session-faulted",
"client-two",
DateTimeOffset.Parse("2026-04-26T10:01:00Z"));
DateTimeOffset.Parse("2026-04-26T10:01:00Z", CultureInfo.InvariantCulture));
faultedSession.AttachWorkerClient(new FakeWorkerClient("session-faulted", 1202, WorkerClientState.Faulted));
faultedSession.MarkFaulted("worker pipe disconnected");
GatewaySession closedSession = CreateSession(
"session-closed",
"client-three",
DateTimeOffset.Parse("2026-04-26T09:59:00Z"));
DateTimeOffset.Parse("2026-04-26T09:59:00Z", CultureInfo.InvariantCulture));
closedSession.AttachWorkerClient(new FakeWorkerClient("session-closed", 1203, WorkerClientState.Closed));
closedSession.TransitionTo(SessionState.Closed);
registry.TryAdd(activeSession);
@@ -102,7 +103,7 @@ public sealed class DashboardSnapshotServiceTests
GatewaySession session = CreateSession(
"session-redacted",
"Bearer mxgw_admin_super-secret",
DateTimeOffset.Parse("2026-04-26T10:00:00Z"),
DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture),
clientSessionName: "password=hunter2",
clientCorrelationId: "token=abc123");
session.MarkFaulted("secret=credential-value");
@@ -131,7 +132,7 @@ public sealed class DashboardSnapshotServiceTests
GatewaySession session = CreateSession(
"session-active",
"client-one",
DateTimeOffset.Parse("2026-04-26T10:00:00Z"));
DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture));
FakeWorkerClient workerClient = new("session-active", 1201, WorkerClientState.Ready);
session.AttachWorkerClient(workerClient);
session.MarkReady();
@@ -160,11 +161,11 @@ public sealed class DashboardSnapshotServiceTests
GatewaySession olderSession = CreateSession(
"session-older",
"client-one",
DateTimeOffset.Parse("2026-04-26T10:00:00Z"));
DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture));
GatewaySession newerSession = CreateSession(
"session-newer",
"client-two",
DateTimeOffset.Parse("2026-04-26T10:01:00Z"));
DateTimeOffset.Parse("2026-04-26T10:01:00Z", CultureInfo.InvariantCulture));
olderSession.MarkFaulted("older fault");
newerSession.MarkFaulted("newer fault");
registry.TryAdd(olderSession);
@@ -199,14 +200,14 @@ public sealed class DashboardSnapshotServiceTests
{
Status = GalaxyCacheStatus.Healthy,
Sequence = 7,
LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z"),
LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z", CultureInfo.InvariantCulture),
DashboardSummary = new DashboardGalaxySummary(
DashboardGalaxyStatus.Healthy,
LastQueriedAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
LastSuccessAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
LastDeployTime: DateTimeOffset.Parse("2026-04-28T09:00:00Z"),
LastQueriedAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
LastSuccessAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
LastDeployTime: DateTimeOffset.Parse("2026-04-28T09:00:00Z", CultureInfo.InvariantCulture),
LastError: null,
ObjectCount: 3,
AreaCount: 1,
@@ -281,7 +282,7 @@ public sealed class DashboardSnapshotServiceTests
{
BrowseSubtrees = ["Area1/*"],
},
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"),
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
LastUsedUtc: null,
RevokedUtc: null));
DashboardSnapshotService service = CreateService(
@@ -314,7 +315,7 @@ public sealed class DashboardSnapshotServiceTests
DisplayName: "Operator",
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty,
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"),
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
LastUsedUtc: null,
RevokedUtc: null));
DashboardSnapshotService service = CreateService(
@@ -520,7 +521,7 @@ public sealed class DashboardSnapshotServiceTests
/// <summary>
/// Gets the timestamp of the last heartbeat.
/// </summary>
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z");
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z", CultureInfo.InvariantCulture);
/// <summary>
/// Gets the count of start invocations.
@@ -13,9 +13,9 @@ public sealed class GatewayApplicationTests
{
/// <summary>Verifies that Build maps the live health check endpoint.</summary>
[Fact]
public void Build_MapsLiveHealthEndpoint()
public async Task Build_MapsLiveHealthEndpoint()
{
WebApplication app = GatewayApplication.Build([]);
await using WebApplication app = GatewayApplication.Build([]);
RouteEndpoint endpoint = Assert.Single(
((IEndpointRouteBuilder)app).DataSources
@@ -28,9 +28,9 @@ public sealed class GatewayApplicationTests
/// <summary>Verifies that Build registers the gateway metrics service.</summary>
[Fact]
public void Build_RegistersGatewayMetrics()
public async Task Build_RegistersGatewayMetrics()
{
WebApplication app = GatewayApplication.Build([]);
await using WebApplication app = GatewayApplication.Build([]);
GatewayMetrics metrics = app.Services.GetRequiredService<GatewayMetrics>();
@@ -39,9 +39,9 @@ public sealed class GatewayApplicationTests
/// <summary>Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled.</summary>
[Fact]
public void Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
public async Task Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
{
WebApplication app = GatewayApplication.Build([]);
await using WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/");
@@ -57,9 +57,9 @@ public sealed class GatewayApplicationTests
/// <summary>Verifies that the dashboard login, logout, and denied endpoints allow anonymous access.</summary>
[Fact]
public void Build_WhenDashboardEnabled_AuthEndpointsAllowAnonymousAccess()
public async Task Build_WhenDashboardEnabled_AuthEndpointsAllowAnonymousAccess()
{
WebApplication app = GatewayApplication.Build([]);
await using WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
string[] anonymousEndpointNames =
@@ -76,9 +76,9 @@ public sealed class GatewayApplicationTests
/// <summary>Verifies that dashboard Razor component routes require the dashboard authorization policy.</summary>
[Fact]
public void Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization()
public async Task Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization()
{
WebApplication app = GatewayApplication.Build([]);
await using WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
string[] componentRoutes =
@@ -99,10 +99,42 @@ public sealed class GatewayApplicationTests
}
}
/// <summary>
/// Server-020 regression: every dashboard Razor page used to declare both
/// <c>@page "/X"</c> and <c>@page "/dashboard/X"</c>, which — once
/// <c>MapGroup("/dashboard")</c> prepended the path base — produced both
/// <c>/dashboard/X</c> AND <c>/dashboard/dashboard/X</c> routes. The second
/// shape was almost certainly accidental and is no longer registered. The
/// check covers every dashboard page so a future page that brings back the
/// duplicated <c>@page</c> directive fails CI.
/// </summary>
[Fact]
public void Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes()
public async Task Build_WhenDashboardEnabled_DoesNotRegisterDoubledDashboardPrefixRoutes()
{
WebApplication app = GatewayApplication.Build(["--MxGateway:Dashboard:Enabled=false"]);
await using WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
string[] doubledRoutes =
[
"/dashboard/dashboard/",
"/dashboard/dashboard/sessions",
"/dashboard/dashboard/workers",
"/dashboard/dashboard/events",
"/dashboard/dashboard/settings",
"/dashboard/dashboard/galaxy",
"/dashboard/dashboard/apikeys",
"/dashboard/dashboard/sessions/{SessionId}",
];
foreach (string doubled in doubledRoutes)
{
Assert.DoesNotContain(endpoints, endpoint => endpoint.RoutePattern.RawText == doubled);
}
}
[Fact]
public async Task Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes()
{
await using WebApplication app = GatewayApplication.Build(["--MxGateway:Dashboard:Enabled=false"]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
Assert.DoesNotContain(endpoints, endpoint =>
@@ -85,6 +85,10 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
Assert.Equal(ProtocolStatusCode.Ok, closeReply.ProtocolStatus.Code);
Assert.Equal(SessionState.Closed, closeReply.FinalState);
Assert.True(launcher.Process.HasExited);
// MarkExited(0) is reached only after the scripted worker observed a WorkerShutdown
// envelope and emitted its WorkerShutdownAck — anything else (a kill, a fault) would
// have produced a non-zero exit code, so this pins the shutdown-ack handshake.
Assert.Equal(0, launcher.Process.ExitCode);
Assert.Equal(
[MxCommandKind.Register, MxCommandKind.AddItem, MxCommandKind.Advise],
launcher.CommandKinds);
@@ -351,6 +355,8 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
private sealed class FakeWorkerProcess(int processId) : IWorkerProcess
{
private readonly TaskCompletionSource _exited = new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>
/// Gets the process identifier.
/// </summary>
@@ -367,15 +373,15 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
public int? ExitCode { get; private set; }
/// <summary>
/// Waits for the process to exit asynchronously.
/// Waits for the process to exit asynchronously. Completes only when <see cref="Kill"/>
/// or <see cref="MarkExited"/> has been called, so callers that observe completion can
/// trust that exit actually happened (e.g., via the worker shutdown-ack path).
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
/// <returns>A task that completes when the process has actually exited.</returns>
public ValueTask WaitForExitAsync(CancellationToken cancellationToken)
{
HasExited = true;
ExitCode ??= 0;
return ValueTask.CompletedTask;
return new ValueTask(_exited.Task.WaitAsync(cancellationToken));
}
/// <summary>
@@ -402,6 +408,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
{
HasExited = true;
ExitCode = exitCode;
_exited.TrySetResult();
}
}
@@ -0,0 +1,757 @@
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Contracts;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Grpc;
using MxGateway.Server.Metrics;
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
using MxGateway.Tests.TestSupport;
namespace MxGateway.Tests.Gateway.Grpc;
/// <summary>
/// Tests for Server-021. <c>MxAccessGatewayService.ApplyConstraintsAsync</c> and
/// the <c>BulkConstraintPlan</c> / <c>ReadBulkConstraintPlan</c> /
/// <c>WriteBulkConstraintPlan</c> / <c>SubscribeBulkConstraintPlan</c> reply-merge
/// logic was previously exercised only with an allow-all enforcer, so denial
/// filtering, the no-allowed-items short-circuit, and the index-ordered
/// denied/allowed interleave were dead code at test time. The fixtures below
/// inject a <see cref="PredicateConstraintEnforcer"/> that denies a subset of
/// tags or handles, and assert the post-merge reply contents and that the
/// session manager is (or is not) invoked.
/// </summary>
public sealed class MxAccessGatewayServiceConstraintTests
{
private const string SessionId = "session-constraint";
// === SubscribeBulk family: AddItemBulk / SubscribeBulk / AdviseItemBulk ===
/// <summary>
/// <c>AddItemBulk</c> with a mix of allowed and denied tags must invoke the
/// worker once with only the allowed tags, then splice the denied entries
/// back into the reply at their original indices.
/// </summary>
[Fact]
public async Task Invoke_AddItemBulk_WithMixedDenials_InterleavesDeniedAndAllowedInOriginalIndexOrder()
{
PredicateConstraintEnforcer enforcer = new()
{
DenyTag = tag => tag == "Tank01.Locked" || tag == "Tank03.Secret",
};
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
sessionManager.InvokeReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = SessionId,
Kind = MxCommandKind.AddItemBulk,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
AddItemBulk = new BulkSubscribeReply
{
Results =
{
// Worker only sees the two allowed tags — Tank02.Open at original
// index 1 and Tank04.Public at original index 3.
new SubscribeResult { ServerHandle = 7, TagAddress = "Tank02.Open", ItemHandle = 102, WasSuccessful = true },
new SubscribeResult { ServerHandle = 7, TagAddress = "Tank04.Public", ItemHandle = 104, WasSuccessful = true },
},
},
},
};
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateAddItemBulkRequest(7, ["Tank01.Locked", "Tank02.Open", "Tank03.Secret", "Tank04.Public"]),
new TestServerCallContext());
Assert.Equal(1, sessionManager.InvokeCount);
// Worker saw only the allowed subset, in original order, with denied entries dropped.
AddItemBulkCommand forwardedCommand = sessionManager.LastWorkerCommand!.Command.AddItemBulk;
Assert.Equal(["Tank02.Open", "Tank04.Public"], forwardedCommand.TagAddresses);
// Final reply preserves the original 4-entry index order, with denied entries
// at index 0 and 2 and worker-allowed entries at index 1 and 3.
BulkSubscribeReply merged = reply.AddItemBulk;
Assert.Equal(4, merged.Results.Count);
Assert.False(merged.Results[0].WasSuccessful);
Assert.Equal("Tank01.Locked", merged.Results[0].TagAddress);
Assert.Contains("Tank01.Locked", merged.Results[0].ErrorMessage, StringComparison.Ordinal);
Assert.True(merged.Results[1].WasSuccessful);
Assert.Equal("Tank02.Open", merged.Results[1].TagAddress);
Assert.Equal(102, merged.Results[1].ItemHandle);
Assert.False(merged.Results[2].WasSuccessful);
Assert.Equal("Tank03.Secret", merged.Results[2].TagAddress);
Assert.True(merged.Results[3].WasSuccessful);
Assert.Equal("Tank04.Public", merged.Results[3].TagAddress);
Assert.Equal(104, merged.Results[3].ItemHandle);
// Both denied tags recorded.
Assert.Equal(2, enforcer.RecordedDenials.Count);
}
/// <summary>
/// <c>SubscribeBulk</c> when every tag is denied must short-circuit
/// <see cref="BulkConstraintPlan.HasAllowedItems"/> false, return the
/// denied-only reply, and never call the session manager.
/// </summary>
[Fact]
public async Task Invoke_SubscribeBulk_WhenAllTagsDenied_DoesNotCallWorkerAndReturnsDeniedReply()
{
PredicateConstraintEnforcer enforcer = new() { DenyTag = _ => true };
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateSubscribeBulkRequest(7, ["A", "B", "C"]),
new TestServerCallContext());
Assert.Equal(0, sessionManager.InvokeCount);
Assert.Equal(3, reply.SubscribeBulk.Results.Count);
Assert.All(reply.SubscribeBulk.Results, r => Assert.False(r.WasSuccessful));
Assert.Equal(["A", "B", "C"], reply.SubscribeBulk.Results.Select(r => r.TagAddress));
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
}
/// <summary>
/// <c>AdviseItemBulk</c> takes handle inputs (not tags) and routes through
/// <c>FilterHandleBulkAsync</c> against <c>CheckReadHandleAsync</c>. Partial
/// denial must still produce a merged-by-index <c>BulkSubscribeReply</c>.
/// </summary>
[Fact]
public async Task Invoke_AdviseItemBulk_WithMixedHandleDenials_MergesDeniedIntoReply()
{
PredicateConstraintEnforcer enforcer = new()
{
DenyReadHandle = (_, itemHandle) => itemHandle == 502,
};
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
sessionManager.InvokeReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = SessionId,
Kind = MxCommandKind.AdviseItemBulk,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
AdviseItemBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult { ServerHandle = 7, ItemHandle = 501, WasSuccessful = true },
new SubscribeResult { ServerHandle = 7, ItemHandle = 503, WasSuccessful = true },
},
},
},
};
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateAdviseItemBulkRequest(7, [501, 502, 503]),
new TestServerCallContext());
Assert.Equal(1, sessionManager.InvokeCount);
Assert.Equal([501, 503], sessionManager.LastWorkerCommand!.Command.AdviseItemBulk.ItemHandles);
BulkSubscribeReply merged = reply.AdviseItemBulk;
Assert.Equal(3, merged.Results.Count);
Assert.True(merged.Results[0].WasSuccessful);
Assert.Equal(501, merged.Results[0].ItemHandle);
Assert.False(merged.Results[1].WasSuccessful);
Assert.Equal(502, merged.Results[1].ItemHandle);
Assert.True(merged.Results[2].WasSuccessful);
Assert.Equal(503, merged.Results[2].ItemHandle);
}
/// <summary>
/// <c>SubscribeBulk</c> with an allow-all enforcer must leave the worker reply
/// unchanged — the constraint plan is null and no merge occurs. Regression
/// guard against accidentally engaging the merge path for the common case.
/// </summary>
[Fact]
public async Task Invoke_SubscribeBulk_WithAllowAllEnforcer_PassesThroughUnchanged()
{
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
sessionManager.InvokeReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = SessionId,
Kind = MxCommandKind.SubscribeBulk,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
SubscribeBulk = new BulkSubscribeReply
{
Results =
{
new SubscribeResult { ServerHandle = 7, TagAddress = "A", ItemHandle = 1, WasSuccessful = true },
new SubscribeResult { ServerHandle = 7, TagAddress = "B", ItemHandle = 2, WasSuccessful = true },
},
},
},
};
MxAccessGatewayService service = CreateService(sessionManager);
MxCommandReply reply = await service.Invoke(
CreateSubscribeBulkRequest(7, ["A", "B"]),
new TestServerCallContext());
Assert.Equal(1, sessionManager.InvokeCount);
Assert.Equal(["A", "B"], sessionManager.LastWorkerCommand!.Command.SubscribeBulk.TagAddresses);
// Reply identical to worker reply — no synthetic denial rows added.
Assert.Equal(2, reply.SubscribeBulk.Results.Count);
Assert.All(reply.SubscribeBulk.Results, r => Assert.True(r.WasSuccessful));
}
// === ReadBulk family ===
/// <summary>
/// <c>ReadBulk</c> with a mix of allowed and denied tags merges denied entries
/// into the <c>BulkReadReply</c> in original-index order, distinguishable from
/// the SubscribeBulk family because the reply slot is
/// <c>BulkReadReply</c>, not <c>BulkSubscribeReply</c>.
/// </summary>
[Fact]
public async Task Invoke_ReadBulk_WithMixedDenials_MergesDeniedBulkReadResults()
{
PredicateConstraintEnforcer enforcer = new()
{
DenyTag = tag => tag == "Secret.Tag",
};
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
sessionManager.InvokeReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = SessionId,
Kind = MxCommandKind.ReadBulk,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
ReadBulk = new BulkReadReply
{
Results =
{
new BulkReadResult { ServerHandle = 7, TagAddress = "Public.A", WasSuccessful = true },
new BulkReadResult { ServerHandle = 7, TagAddress = "Public.B", WasSuccessful = true },
},
},
},
};
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateReadBulkRequest(7, ["Public.A", "Secret.Tag", "Public.B"]),
new TestServerCallContext());
Assert.Equal(1, sessionManager.InvokeCount);
Assert.Equal(["Public.A", "Public.B"], sessionManager.LastWorkerCommand!.Command.ReadBulk.TagAddresses);
BulkReadReply merged = reply.ReadBulk;
Assert.Equal(3, merged.Results.Count);
Assert.True(merged.Results[0].WasSuccessful);
Assert.False(merged.Results[1].WasSuccessful);
Assert.Equal("Secret.Tag", merged.Results[1].TagAddress);
Assert.True(merged.Results[2].WasSuccessful);
}
/// <summary>
/// <c>ReadBulk</c> with all tags denied must short-circuit and produce a
/// denied-only <c>BulkReadReply</c> — verifying
/// <see cref="MxAccessGatewayService"/>'s <c>ReadBulkConstraintPlan</c>
/// <c>CreateDeniedReply</c> path.
/// </summary>
[Fact]
public async Task Invoke_ReadBulk_WhenAllTagsDenied_ShortCircuitsWithDeniedOnlyReply()
{
PredicateConstraintEnforcer enforcer = new() { DenyTag = _ => true };
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateReadBulkRequest(7, ["X", "Y"]),
new TestServerCallContext());
Assert.Equal(0, sessionManager.InvokeCount);
Assert.Equal(2, reply.ReadBulk.Results.Count);
Assert.All(reply.ReadBulk.Results, r => Assert.False(r.WasSuccessful));
Assert.Equal(MxCommandKind.ReadBulk, reply.Kind);
}
// === WriteBulk family: WriteBulk / Write2Bulk / WriteSecuredBulk / WriteSecured2Bulk ===
/// <summary>
/// <c>WriteBulk</c> with one denied handle must drop that entry from the
/// forwarded command and splice a denied <c>BulkWriteResult</c> back in at
/// the original index.
/// </summary>
[Fact]
public async Task Invoke_WriteBulk_WithDeniedHandle_DropsEntryFromWorkerCallAndMergesDenialIntoReply()
{
PredicateConstraintEnforcer enforcer = new()
{
DenyWriteHandle = (_, itemHandle) => itemHandle == 902,
};
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
sessionManager.InvokeReply = new WorkerCommandReply
{
Reply = new MxCommandReply
{
SessionId = SessionId,
Kind = MxCommandKind.WriteBulk,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
WriteBulk = new BulkWriteReply
{
Results =
{
new BulkWriteResult { ServerHandle = 7, ItemHandle = 901, WasSuccessful = true },
new BulkWriteResult { ServerHandle = 7, ItemHandle = 903, WasSuccessful = true },
},
},
},
};
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateWriteBulkRequest(7, [901, 902, 903]),
new TestServerCallContext());
Assert.Equal(1, sessionManager.InvokeCount);
// 902 dropped from forwarded entries; only 901 and 903 reach the worker.
WriteBulkCommand forwarded = sessionManager.LastWorkerCommand!.Command.WriteBulk;
Assert.Equal([901, 903], forwarded.Entries.Select(e => e.ItemHandle));
BulkWriteReply merged = reply.WriteBulk;
Assert.Equal(3, merged.Results.Count);
Assert.True(merged.Results[0].WasSuccessful);
Assert.Equal(901, merged.Results[0].ItemHandle);
Assert.False(merged.Results[1].WasSuccessful);
Assert.Equal(902, merged.Results[1].ItemHandle);
Assert.True(merged.Results[2].WasSuccessful);
Assert.Equal(903, merged.Results[2].ItemHandle);
}
/// <summary>
/// <c>WriteSecuredBulk</c> exercises a different <c>ReplaceWriteBulkEntries</c>
/// switch arm than plain <c>WriteBulk</c>. The merge logic is shared, so a
/// full denial here is enough to prove the secured-bulk routing.
/// </summary>
[Fact]
public async Task Invoke_WriteSecuredBulk_WhenAllHandlesDenied_ShortCircuitsWithDeniedOnlyReply()
{
PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true };
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
MxCommandReply reply = await service.Invoke(
CreateWriteSecuredBulkRequest(7, [10, 11]),
new TestServerCallContext());
Assert.Equal(0, sessionManager.InvokeCount);
Assert.Equal(MxCommandKind.WriteSecuredBulk, reply.Kind);
Assert.Equal(2, reply.WriteSecuredBulk.Results.Count);
Assert.All(reply.WriteSecuredBulk.Results, r => Assert.False(r.WasSuccessful));
}
// === Unary write-handle enforcement (EnforceWriteHandleAsync) ===
/// <summary>
/// Unary <c>Write</c> against a denied (server, item) handle must surface
/// <see cref="StatusCode.PermissionDenied"/> via <c>EnforceWriteHandleAsync</c>
/// and never reach the session manager.
/// </summary>
[Fact]
public async Task Invoke_Write_WithDeniedHandle_ThrowsPermissionDeniedAndDoesNotCallWorker()
{
PredicateConstraintEnforcer enforcer = new()
{
DenyWriteHandle = (serverHandle, itemHandle) => serverHandle == 7 && itemHandle == 42,
};
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.Invoke(
CreateWriteRequest(serverHandle: 7, itemHandle: 42),
new TestServerCallContext()));
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
Assert.Equal(0, sessionManager.InvokeCount);
Assert.Single(enforcer.RecordedDenials);
Assert.Equal("42", enforcer.RecordedDenials[0].Target);
}
/// <summary>
/// Unary <c>WriteSecured</c> against a denied handle takes the same enforce path
/// and rejects identically — proving the four-arm switch in
/// <c>ApplyConstraintsAsync</c> (Write/Write2/WriteSecured/WriteSecured2) is
/// reachable for at least one of the secured kinds.
/// </summary>
[Fact]
public async Task Invoke_WriteSecured_WithDeniedHandle_ThrowsPermissionDenied()
{
PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true };
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.Invoke(
CreateWriteSecuredRequest(serverHandle: 7, itemHandle: 42),
new TestServerCallContext()));
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
Assert.Equal(0, sessionManager.InvokeCount);
}
// === Unary read-tag enforcement (EnforceReadTagAsync via AddItem) ===
/// <summary>
/// Unary <c>AddItem</c> against a denied tag must surface
/// <see cref="StatusCode.PermissionDenied"/> via <c>EnforceReadTagAsync</c>
/// and never reach the session manager.
/// </summary>
[Fact]
public async Task Invoke_AddItem_WithDeniedTag_ThrowsPermissionDeniedAndDoesNotCallWorker()
{
PredicateConstraintEnforcer enforcer = new()
{
DenyTag = tag => tag == "Secret.Tag",
};
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.Invoke(
CreateAddItemRequest(serverHandle: 7, tagAddress: "Secret.Tag"),
new TestServerCallContext()));
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
Assert.Equal(0, sessionManager.InvokeCount);
Assert.Single(enforcer.RecordedDenials);
Assert.Equal("Secret.Tag", enforcer.RecordedDenials[0].Target);
}
// === Helpers ===
private static MxAccessGatewayService CreateService(
FakeSessionManager sessionManager,
IConstraintEnforcer? constraintEnforcer = null)
{
return new MxAccessGatewayService(
sessionManager,
new GatewayRequestIdentityAccessor(),
constraintEnforcer ?? new AllowAllConstraintEnforcer(),
new MxAccessGrpcRequestValidator(),
new MxAccessGrpcMapper(),
new FakeEventStreamService(sessionManager),
new GatewayMetrics(),
NullLogger<MxAccessGatewayService>.Instance);
}
private static FakeSessionManager CreateSessionManagerWithSeed()
{
FakeSessionManager sessionManager = new() { ResolveOnlySeededSessions = true };
sessionManager.SeedSession(CreateSession(SessionId));
return sessionManager;
}
private static GatewaySession CreateSession(string sessionId)
{
GatewaySession session = new(
sessionId,
GatewayContractInfo.DefaultBackendName,
"pipe",
"nonce",
"Operator Key",
"operator-session",
"client-correlation",
TimeSpan.FromSeconds(7),
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(10),
DateTimeOffset.UtcNow);
session.AttachWorkerClient(new FakeWorkerClient());
session.MarkReady();
return session;
}
private static MxCommandRequest CreateAddItemBulkRequest(int serverHandle, IReadOnlyList<string> tags)
{
AddItemBulkCommand cmd = new() { ServerHandle = serverHandle };
cmd.TagAddresses.Add(tags);
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand { Kind = MxCommandKind.AddItemBulk, AddItemBulk = cmd },
};
}
private static MxCommandRequest CreateSubscribeBulkRequest(int serverHandle, IReadOnlyList<string> tags)
{
SubscribeBulkCommand cmd = new() { ServerHandle = serverHandle };
cmd.TagAddresses.Add(tags);
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand { Kind = MxCommandKind.SubscribeBulk, SubscribeBulk = cmd },
};
}
private static MxCommandRequest CreateAdviseItemBulkRequest(int serverHandle, IReadOnlyList<int> itemHandles)
{
AdviseItemBulkCommand cmd = new() { ServerHandle = serverHandle };
cmd.ItemHandles.Add(itemHandles);
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand { Kind = MxCommandKind.AdviseItemBulk, AdviseItemBulk = cmd },
};
}
private static MxCommandRequest CreateReadBulkRequest(int serverHandle, IReadOnlyList<string> tags)
{
ReadBulkCommand cmd = new() { ServerHandle = serverHandle, TimeoutMs = 1000 };
cmd.TagAddresses.Add(tags);
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand { Kind = MxCommandKind.ReadBulk, ReadBulk = cmd },
};
}
private static MxCommandRequest CreateWriteBulkRequest(int serverHandle, IReadOnlyList<int> itemHandles)
{
WriteBulkCommand cmd = new() { ServerHandle = serverHandle };
foreach (int handle in itemHandles)
{
cmd.Entries.Add(new WriteBulkEntry { ItemHandle = handle, Value = new MxValue { StringValue = "v" } });
}
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand { Kind = MxCommandKind.WriteBulk, WriteBulk = cmd },
};
}
private static MxCommandRequest CreateWriteSecuredBulkRequest(int serverHandle, IReadOnlyList<int> itemHandles)
{
WriteSecuredBulkCommand cmd = new() { ServerHandle = serverHandle };
foreach (int handle in itemHandles)
{
cmd.Entries.Add(new WriteSecuredBulkEntry
{
ItemHandle = handle,
CurrentUserId = 1,
VerifierUserId = 2,
Value = new MxValue { StringValue = "v" },
});
}
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand { Kind = MxCommandKind.WriteSecuredBulk, WriteSecuredBulk = cmd },
};
}
private static MxCommandRequest CreateWriteRequest(int serverHandle, int itemHandle)
{
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand
{
Kind = MxCommandKind.Write,
Write = new WriteCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
Value = new MxValue { StringValue = "v" },
},
},
};
}
private static MxCommandRequest CreateWriteSecuredRequest(int serverHandle, int itemHandle)
{
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand
{
Kind = MxCommandKind.WriteSecured,
WriteSecured = new WriteSecuredCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
CurrentUserId = 1,
VerifierUserId = 2,
Value = new MxValue { StringValue = "v" },
},
},
};
}
private static MxCommandRequest CreateAddItemRequest(int serverHandle, string tagAddress)
{
return new MxCommandRequest
{
SessionId = SessionId,
Command = new MxCommand
{
Kind = MxCommandKind.AddItem,
AddItem = new AddItemCommand
{
ServerHandle = serverHandle,
ItemDefinition = tagAddress,
},
},
};
}
// FakeSessionManager / FakeEventStreamService / FakeWorkerClient mirror the
// implementations in MxAccessGatewayServiceTests; the duplication is intentional
// so the constraint tests are self-contained and changes to the existing fakes
// don't accidentally couple the two suites.
private sealed class FakeSessionManager : ISessionManager
{
private readonly Dictionary<string, GatewaySession> seededSessions = new(StringComparer.Ordinal);
public bool ResolveOnlySeededSessions { get; init; }
public WorkerCommand? LastWorkerCommand { get; private set; }
public int InvokeCount { get; private set; }
public WorkerCommandReply InvokeReply { get; set; } = new()
{
Reply = new MxCommandReply
{
SessionId = SessionId,
Kind = MxCommandKind.Ping,
ProtocolStatus = MxAccessGrpcMapper.Ok(),
},
};
public List<WorkerEvent> Events { get; } = [];
public void SeedSession(GatewaySession session) => seededSessions[session.SessionId] = session;
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
CancellationToken cancellationToken) =>
Task.FromResult(seededSessions.Values.First());
public bool TryGetSession(string sessionId, out GatewaySession session)
{
if (seededSessions.TryGetValue(sessionId, out GatewaySession? seeded))
{
session = seeded;
return true;
}
if (ResolveOnlySeededSessions)
{
session = null!;
return false;
}
session = CreateFallbackSession(sessionId);
return true;
}
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken)
{
InvokeCount++;
LastWorkerCommand = command;
return Task.FromResult(InvokeReply);
}
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (WorkerEvent ev in Events)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return ev;
}
}
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken) =>
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken) => Task.FromResult(0);
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private static GatewaySession CreateFallbackSession(string sessionId)
{
GatewaySession session = new(
sessionId,
GatewayContractInfo.DefaultBackendName,
"pipe",
"nonce",
"Operator Key",
"operator-session",
"client-correlation",
TimeSpan.FromSeconds(7),
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(10),
DateTimeOffset.UtcNow);
session.AttachWorkerClient(new FakeWorkerClient());
session.MarkReady();
return session;
}
}
private sealed class FakeEventStreamService(FakeSessionManager sessionManager) : IEventStreamService
{
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
foreach (WorkerEvent ev in sessionManager.Events)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return ev.Event;
}
}
}
private sealed class FakeWorkerClient : IWorkerClient
{
public string SessionId { get; } = MxAccessGatewayServiceConstraintTests.SessionId;
public int? ProcessId { get; } = 1234;
public WorkerClientState State { get; } = WorkerClientState.Ready;
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
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(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
public void Kill(string reason)
{
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -422,12 +422,13 @@ public sealed class MxAccessGatewayServiceTests
private static MxAccessGatewayService CreateService(
FakeSessionManager sessionManager,
IGatewayRequestIdentityAccessor? identityAccessor = null,
GatewayMetrics? metrics = null)
GatewayMetrics? metrics = null,
IConstraintEnforcer? constraintEnforcer = null)
{
return new MxAccessGatewayService(
sessionManager,
identityAccessor ?? new GatewayRequestIdentityAccessor(),
new AllowAllConstraintEnforcer(),
constraintEnforcer ?? new AllowAllConstraintEnforcer(),
new MxAccessGrpcRequestValidator(),
new MxAccessGrpcMapper(),
new FakeEventStreamService(sessionManager),
@@ -0,0 +1,247 @@
using System.Runtime.CompilerServices;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
namespace 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-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,711 @@
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.Options;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Configuration;
using MxGateway.Server.Metrics;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
namespace 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));
}
[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)
{
SessionManager manager = CreateManager(workerClient);
return await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
}
private static SessionManager CreateManager(FakeBulkWorkerClient 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;
}
}
@@ -37,7 +37,7 @@ public sealed class SessionManagerTests
[Fact]
public async Task OpenSessionAsync_SetsInitialDefaultLease()
{
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-29T10:00:00Z"));
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()),
@@ -267,21 +267,45 @@ public sealed class WorkerAlarmRpcDispatcherTests
Assert.Equal("Galaxy!A.T2", collected[1].AlarmFullReference);
}
/// <summary>
/// Server-019 regression: <c>QueryActiveAlarmsAsync</c> used to silently
/// <c>yield break</c> when the session id was not in the registry, while the
/// peer <c>AcknowledgeAsync</c> returned <c>SessionNotFound</c>. Both methods
/// now signal a missing session — <c>QueryActiveAlarms</c> throws a
/// <see cref="SessionManagerException"/> with
/// <see cref="SessionManagerErrorCode.SessionNotFound"/> (the gateway gRPC
/// layer maps it to gRPC <c>NotFound</c>), aligning the dispatcher's
/// missing-session contract across the two RPCs.
/// </summary>
[Fact]
public async Task QueryActiveAlarmsAsync_WhenSessionMissing_YieldsEmpty()
public async Task QueryActiveAlarmsAsync_WhenSessionMissing_ThrowsSessionNotFound()
{
SessionRegistry registry = new();
WorkerAlarmRpcDispatcher dispatcher = new(registry);
List<ActiveAlarmSnapshot> collected = new();
await foreach (ActiveAlarmSnapshot snap in dispatcher.QueryActiveAlarmsAsync(
new QueryActiveAlarmsRequest { SessionId = "missing" },
CancellationToken.None))
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(async () =>
{
collected.Add(snap);
}
await foreach (ActiveAlarmSnapshot _ in dispatcher.QueryActiveAlarmsAsync(
new QueryActiveAlarmsRequest { SessionId = "missing" },
CancellationToken.None))
{
// No yield expected — the throw happens before the first iteration.
}
});
Assert.Empty(collected);
Assert.Equal(SessionManagerErrorCode.SessionNotFound, exception.ErrorCode);
// Peer-method parity: AcknowledgeAsync still signals SessionNotFound (as an
// in-band ProtocolStatus, since it's a unary RPC). The two methods now agree
// that a missing session is an error, not an empty success.
AcknowledgeAlarmReply ackReply = await dispatcher.AcknowledgeAsync(
new AcknowledgeAlarmRequest
{
SessionId = "missing",
AlarmFullReference = Guid.NewGuid().ToString(),
},
CancellationToken.None);
Assert.Equal(ProtocolStatusCode.SessionNotFound, ackReply.ProtocolStatus.Code);
}
[Fact]
@@ -341,10 +341,17 @@ public sealed class WorkerClientTests
Assert.Equal(previousHeartbeat + TimeSpan.FromSeconds(1), client.LastHeartbeatAt);
}
/// <summary>Verifies that the heartbeat monitor faults the client when the heartbeat expires.</summary>
/// <summary>
/// Verifies that the heartbeat monitor faults the client when the heartbeat expires.
/// Uses an injected <see cref="ManualTimeProvider"/> so the grace comparison is deterministic
/// instead of depending on real wall-clock advance; the monitor's
/// <see cref="WorkerClientOptions.HeartbeatCheckInterval"/> timer stays on the real clock and
/// observes the manually-advanced grace on its next tick.
/// </summary>
[Fact]
public async Task HeartbeatMonitor_WhenHeartbeatExpires_FaultsClient()
{
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-05-20T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture));
await using PipePair pipePair = await PipePair.CreateAsync();
await using WorkerClient client = CreateClient(
pipePair,
@@ -353,9 +360,12 @@ public sealed class WorkerClientTests
HeartbeatGrace = TimeSpan.FromMilliseconds(80),
HeartbeatCheckInterval = TimeSpan.FromMilliseconds(20),
EventChannelCapacity = 8,
});
},
timeProvider: clock);
await CompleteHandshakeAsync(client, pipePair);
clock.Advance(TimeSpan.FromSeconds(2));
await WaitUntilAsync(
() => client.State == WorkerClientState.Faulted,
TestTimeout);
@@ -252,6 +252,94 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
Assert.Equal(0, sessionManager.InvokeCount);
}
/// <summary>
/// Verifies the interceptor denies <c>AcknowledgeAlarm</c> calls that lack
/// <see cref="GatewayScopes.InvokeWrite"/>. Ack is a write-shaped mutation against
/// alarm state, so it carries the same scope as <c>MxCommandKind.Write</c>.
/// </summary>
[Fact]
public async Task UnaryServerHandler_AcknowledgeAlarmMissingScope_ReturnsPermissionDenied()
{
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeRead)),
new GatewayRequestIdentityAccessor());
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => interceptor.UnaryServerHandler(
new AcknowledgeAlarmRequest { SessionId = "session-1", AlarmFullReference = "ref" },
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
(_, _) => Task.FromResult(new AcknowledgeAlarmReply())));
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
Assert.Contains(GatewayScopes.InvokeWrite, exception.Status.Detail, StringComparison.Ordinal);
}
/// <summary>Verifies that an API key holding <c>invoke:write</c> may call <c>AcknowledgeAlarm</c>.</summary>
[Fact]
public async Task UnaryServerHandler_AcknowledgeAlarmWithScope_RunsHandler()
{
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeWrite)),
new GatewayRequestIdentityAccessor());
bool handlerRan = false;
AcknowledgeAlarmReply reply = await interceptor.UnaryServerHandler(
new AcknowledgeAlarmRequest { SessionId = "session-1", AlarmFullReference = "ref" },
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
(_, _) =>
{
handlerRan = true;
return Task.FromResult(new AcknowledgeAlarmReply());
});
Assert.NotNull(reply);
Assert.True(handlerRan);
}
/// <summary>
/// Verifies the interceptor denies <c>QueryActiveAlarms</c> server-streaming calls that
/// lack <see cref="GatewayScopes.EventsRead"/>. Active-alarm snapshots are part of the
/// alarm/event surface and share the same scope as <c>StreamEvents</c>.
/// </summary>
[Fact]
public async Task ServerStreamingServerHandler_QueryActiveAlarmsMissingScope_ReturnsPermissionDenied()
{
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeRead)),
new GatewayRequestIdentityAccessor());
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => interceptor.ServerStreamingServerHandler(
new QueryActiveAlarmsRequest { SessionId = "session-1" },
new RecordingServerStreamWriter<ActiveAlarmSnapshot>(),
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
(_, _, _) => Task.CompletedTask));
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
Assert.Contains(GatewayScopes.EventsRead, exception.Status.Detail, StringComparison.Ordinal);
}
/// <summary>Verifies that an API key holding <c>events:read</c> may call <c>QueryActiveAlarms</c>.</summary>
[Fact]
public async Task ServerStreamingServerHandler_QueryActiveAlarmsWithScope_RunsHandler()
{
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)),
new GatewayRequestIdentityAccessor());
RecordingServerStreamWriter<ActiveAlarmSnapshot> streamWriter = new();
await interceptor.ServerStreamingServerHandler(
new QueryActiveAlarmsRequest { SessionId = "session-1" },
streamWriter,
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
async (_, writer, _) =>
{
await writer.WriteAsync(new ActiveAlarmSnapshot());
});
Assert.Single(streamWriter.Messages);
}
private static MxAccessGatewayService CreateService(
ISessionManager sessionManager,
IGatewayRequestIdentityAccessor identityAccessor)
@@ -13,6 +13,8 @@ public sealed class GatewayGrpcScopeResolverTests
[InlineData(typeof(OpenSessionRequest), GatewayScopes.SessionOpen)]
[InlineData(typeof(CloseSessionRequest), GatewayScopes.SessionClose)]
[InlineData(typeof(StreamEventsRequest), GatewayScopes.EventsRead)]
[InlineData(typeof(AcknowledgeAlarmRequest), GatewayScopes.InvokeWrite)]
[InlineData(typeof(QueryActiveAlarmsRequest), GatewayScopes.EventsRead)]
[InlineData(typeof(TestConnectionRequest), GatewayScopes.MetadataRead)]
[InlineData(typeof(GetLastDeployTimeRequest), GatewayScopes.MetadataRead)]
[InlineData(typeof(DiscoverHierarchyRequest), GatewayScopes.MetadataRead)]
@@ -0,0 +1,89 @@
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
using MxGateway.Server.Sessions;
namespace MxGateway.Tests.TestSupport;
/// <summary>
/// <see cref="IConstraintEnforcer"/> for tests that exercise the constraint
/// filtering and reply-merging code paths in
/// <c>MxAccessGatewayService.ApplyConstraintsAsync</c> and the
/// <c>BulkConstraintPlan</c> family. Callers supply predicates that decide
/// whether a given tag address or (server, item) handle is denied; recorded
/// denials are exposed for assertions.
/// </summary>
public sealed class PredicateConstraintEnforcer : IConstraintEnforcer
{
/// <summary>Deny predicate keyed on tag address (returns true to deny).</summary>
public Func<string, bool> DenyTag { get; init; } = _ => false;
/// <summary>Deny predicate keyed on (serverHandle, itemHandle) (returns true to deny).</summary>
public Func<int, int, bool> DenyReadHandle { get; init; } = (_, _) => false;
/// <summary>Deny predicate keyed on (serverHandle, itemHandle) (returns true to deny).</summary>
public Func<int, int, bool> DenyWriteHandle { get; init; } = (_, _) => false;
/// <summary>Recorded denial messages — (commandKind, target) tuples.</summary>
public List<(string CommandKind, string Target)> RecordedDenials { get; } = [];
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity,
string tagAddress,
CancellationToken cancellationToken)
{
if (DenyTag(tagAddress))
{
return Task.FromResult<ConstraintFailure?>(
new ConstraintFailure("read-tag", $"Read denied for tag '{tagAddress}'."));
}
return Task.FromResult<ConstraintFailure?>(null);
}
/// <inheritdoc />
public Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
int serverHandle,
int itemHandle,
CancellationToken cancellationToken)
{
if (DenyReadHandle(serverHandle, itemHandle))
{
return Task.FromResult<ConstraintFailure?>(
new ConstraintFailure("read-handle", $"Read denied for handle {itemHandle}."));
}
return Task.FromResult<ConstraintFailure?>(null);
}
/// <inheritdoc />
public Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
int serverHandle,
int itemHandle,
CancellationToken cancellationToken)
{
if (DenyWriteHandle(serverHandle, itemHandle))
{
return Task.FromResult<ConstraintFailure?>(
new ConstraintFailure("write-handle", $"Write denied for handle {itemHandle}."));
}
return Task.FromResult<ConstraintFailure?>(null);
}
/// <inheritdoc />
public Task RecordDenialAsync(
ApiKeyIdentity? identity,
string commandKind,
string target,
ConstraintFailure failure,
CancellationToken cancellationToken)
{
RecordedDenials.Add((commandKind, target));
return Task.CompletedTask;
}
}
@@ -129,6 +129,109 @@ public sealed class WorkerFrameProtocolTests
Assert.Equal(WorkerFrameProtocolErrorCode.InvalidEnvelope, exception.ErrorCode);
}
/// <summary>
/// Worker.Tests-021 (a): pins the <c>EndOfStream</c> branch of
/// <c>WorkerFrameReader.ReadExactlyOrThrowAsync</c>. The gateway
/// closing its end of the pipe during a partial-frame read is the
/// most common production transport failure; the reader must
/// surface this as <c>WorkerFrameProtocolErrorCode.EndOfStream</c>
/// so the worker session can fault deterministically rather than
/// spinning on a partial buffer. The stream here declares a 100-byte
/// payload but only supplies 50 bytes, so the inner read loop sees
/// <c>bytesRead == 0</c> mid-frame.
/// </summary>
[Fact]
public async Task ReadAsync_WhenStreamEndsMidFrame_ThrowsEndOfStream()
{
WorkerFrameProtocolOptions options = CreateOptions();
byte[] frame = new byte[sizeof(uint) + 50];
WorkerFrameTestHelpers.WriteUInt32LittleEndian(frame, 100);
using MemoryStream stream = new(frame);
WorkerFrameReader reader = new(stream, options);
WorkerFrameProtocolException exception =
await Assert.ThrowsAsync<WorkerFrameProtocolException>(
async () => await reader.ReadAsync());
Assert.Equal(WorkerFrameProtocolErrorCode.EndOfStream, exception.ErrorCode);
}
/// <summary>
/// Worker.Tests-021 (b): pins the writer-side
/// <c>MessageTooLarge</c> branch. A session that constructs an
/// envelope whose serialised size exceeds <c>MaxMessageBytes</c>
/// must be rejected by the writer before any bytes are sent down
/// the pipe, so a misbehaving producer cannot push the receiver
/// past its bounds. A small <c>MaxMessageBytes</c> is configured
/// so a modest <c>GatewayHello</c> payload — with its nonce
/// padded out to several hundred bytes — exceeds the limit
/// without allocating anything large.
/// </summary>
[Fact]
public async Task WriteAsync_WithEnvelopeAboveConfiguredMaximum_ThrowsMessageTooLarge()
{
const int maxMessageBytes = 64;
WorkerFrameProtocolOptions options = new(
SessionId,
GatewayContractInfo.WorkerProtocolVersion,
Nonce,
maxMessageBytes);
using MemoryStream stream = new();
WorkerFrameWriter writer = new(stream, options);
WorkerEnvelope envelope = CreateGatewayHelloEnvelope();
envelope.GatewayHello.GatewayVersion = new string('x', 1024);
WorkerFrameProtocolException exception =
await Assert.ThrowsAsync<WorkerFrameProtocolException>(
async () => await writer.WriteAsync(envelope));
Assert.Equal(WorkerFrameProtocolErrorCode.MessageTooLarge, exception.ErrorCode);
Assert.Equal(0, stream.Length);
}
/// <summary>
/// Worker.Tests-021 (c): documents that the writer-side
/// <c>InvalidEnvelope</c> branch (raised when
/// <c>WorkerEnvelope.CalculateSize()</c> returns 0) is unreachable
/// through public API. <c>WorkerEnvelopeValidator.Validate</c> (run
/// before the size check in <c>WorkerFrameWriter.WriteAsync</c>)
/// rejects any envelope whose <c>BodyCase</c> is <c>None</c> with
/// <c>InvalidEnvelope</c>; a body-less envelope is therefore
/// intercepted before the empty-payload branch can fire. Any
/// envelope carrying a typed body serialises at least the field
/// tag bytes, so <c>CalculateSize()</c> is strictly positive. This
/// test exercises the body-less path and asserts the same
/// <c>InvalidEnvelope</c> error code reaches the caller, pinning
/// the contract that "no body" is rejected before any size check.
/// The defensive zero-length branch in <c>WriteAsync</c> is left
/// in place because the cost is one comparison and removing it
/// would weaken the writer against future serialisation
/// regressions; this test makes its rationale visible.
/// </summary>
[Fact]
public async Task WriteAsync_WithEmptyEnvelope_ThrowsInvalidEnvelopeFromValidator()
{
WorkerFrameProtocolOptions options = CreateOptions();
using MemoryStream stream = new();
WorkerFrameWriter writer = new(stream, options);
WorkerEnvelope envelope = new()
{
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
SessionId = SessionId,
Sequence = 1,
// No body — BodyCase == None, validator rejects.
};
WorkerFrameProtocolException exception =
await Assert.ThrowsAsync<WorkerFrameProtocolException>(
async () => await writer.WriteAsync(envelope));
Assert.Equal(WorkerFrameProtocolErrorCode.InvalidEnvelope, exception.ErrorCode);
Assert.Equal(0, stream.Length);
}
/// <summary>Verifies that concurrent writes produce complete serialized frames.</summary>
[Fact]
public async Task WriteAsync_WithConcurrentCalls_SerializesCompleteFrames()
@@ -10,7 +10,6 @@ using MxGateway.Contracts;
using MxGateway.Contracts.Proto;
using MxGateway.Worker.Ipc;
using MxGateway.Worker.MxAccess;
using MxGateway.Worker.Sta;
using MxGateway.Worker.Tests.TestSupport;
namespace MxGateway.Worker.Tests.Ipc;
@@ -290,7 +289,14 @@ public sealed class WorkerPipeSessionTests
}
/// <summary>Verifies that stale STA activity triggers watchdog fault.</summary>
/// <summary>
/// Verifies that stale STA activity with no command in flight triggers
/// the watchdog StaHung fault. Worker-017 changed the watchdog to skip
/// the fault while a command is in flight (the worker is busy
/// executing it, not hung), so this test deliberately leaves the
/// current-command correlation id empty to assert the genuine-hung
/// path still fires.
/// </summary>
[Fact]
public async Task RunAsync_WhenStaActivityIsStale_WritesWatchdogFault()
{
@@ -302,7 +308,7 @@ public sealed class WorkerPipeSessionTests
pendingCommandCount: 0,
outboundEventQueueDepth: 0,
lastEventSequence: 0,
currentCommandCorrelationId: "stuck-command"));
currentCommandCorrelationId: string.Empty));
WorkerPipeSession session = CreatePipeSession(
pipePair.WorkerStream,
runtime,
@@ -320,12 +326,75 @@ public sealed class WorkerPipeSessionTests
cancellation.Token);
Assert.Equal(WorkerFaultCategory.StaHung, fault.WorkerFault.Category);
Assert.Equal("stuck-command", fault.WorkerFault.CommandMethod);
Assert.Contains("STA activity is stale", fault.WorkerFault.DiagnosticMessage);
await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token);
}
/// <summary>
/// Worker-017 regression: while a command is in flight (snapshot's
/// current command correlation id is non-empty), stale STA activity
/// must NOT trigger the watchdog StaHung fault. The STA is busy
/// executing the command, not hung; <c>StaRuntime.ProcessQueuedCommands</c>
/// only calls <c>MarkActivity()</c> before and after each work item,
/// so a synchronously long-running command (e.g. <c>ReadBulk</c>
/// waiting <c>timeout_ms</c> for OnDataChange) legitimately freezes
/// <c>LastActivityUtc</c>. The heartbeat already advertises the
/// in-flight correlation id so the gateway can apply its own per-command
/// timeout.
/// </summary>
[Fact]
public async Task RunAsync_WhenStaActivityIsStaleWithCommandInFlight_DoesNotWriteWatchdogFault()
{
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(10));
using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token);
FakeRuntimeSession runtime = new();
runtime.SetSnapshot(new WorkerRuntimeHeartbeatSnapshot(
DateTimeOffset.UtcNow - TimeSpan.FromSeconds(5),
pendingCommandCount: 0,
outboundEventQueueDepth: 0,
lastEventSequence: 0,
currentCommandCorrelationId: "slow-bulk-read"));
WorkerPipeSession session = CreatePipeSession(
pipePair.WorkerStream,
runtime,
new WorkerPipeSessionOptions
{
HeartbeatInterval = TimeSpan.FromMilliseconds(20),
HeartbeatGrace = TimeSpan.FromMilliseconds(50),
});
Task runTask = session.RunAsync(cancellation.Token);
await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token);
// Read several frames over a window much larger than HeartbeatGrace.
// None must be a WorkerFault; multiple heartbeats must all carry the
// in-flight correlation id. Reading a bounded count of frames keeps
// the pipe frame-aligned for the subsequent shutdown handshake.
const int framesToInspect = 6;
int heartbeatsObserved = 0;
for (int index = 0; index < framesToInspect; index++)
{
WorkerEnvelope envelope = await pipePair.GatewayReader
.ReadAsync(cancellation.Token);
Assert.NotEqual(
WorkerEnvelope.BodyOneofCase.WorkerFault,
envelope.BodyCase);
if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerHeartbeat)
{
Assert.Equal(
"slow-bulk-read",
envelope.WorkerHeartbeat.CurrentCommandCorrelationId);
heartbeatsObserved++;
}
}
Assert.True(
heartbeatsObserved >= 2,
$"Expected multiple heartbeats during in-flight command window; observed {heartbeatsObserved}.");
await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token);
}
/// <summary>
/// Worker-004 regression: once the watchdog reports an StaHung fault,
/// subsequent heartbeats must report <see cref="WorkerState.Faulted"/>
@@ -531,6 +600,89 @@ public sealed class WorkerPipeSessionTests
await runTask;
}
/// <summary>
/// Worker.Tests-017 regression: the <c>WorkerCancel</c> branch of
/// <see cref="WorkerPipeSession.DispatchGatewayEnvelopeAsync"/> must
/// forward the envelope's correlation id to the runtime session via
/// <see cref="IWorkerRuntimeSession.CancelCommand"/> and keep the
/// message loop running (no fault, no exit). The handler dispatch
/// returns <c>true</c> (keep reading), so a subsequent
/// <c>WorkerShutdown</c> still produces the normal shutdown ack.
/// </summary>
[Fact]
public async Task RunAsync_WhenGatewaySendsWorkerCancel_ForwardsCorrelationIdToRuntimeSession()
{
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(5));
using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token);
FakeRuntimeSession runtime = new();
WorkerPipeSession session = CreatePipeSession(
pipePair.WorkerStream,
runtime,
new WorkerPipeSessionOptions
{
HeartbeatInterval = TimeSpan.FromSeconds(1),
HeartbeatGrace = TimeSpan.FromSeconds(5),
});
Task runTask = session.RunAsync(cancellation.Token);
await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token);
await pipePair.GatewayWriter
.WriteAsync(CreateCancelEnvelope("cancel-correlation-1"), cancellation.Token);
// The session must remain in its message loop: send a follow-up
// shutdown and observe the normal ack. If WorkerCancel had faulted
// the pipe or exited the loop, the ack would never arrive.
await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token);
Assert.Contains("cancel-correlation-1", runtime.CancelledCorrelationIds);
}
/// <summary>
/// Worker.Tests-017 regression: the <c>default:</c> arm of
/// <see cref="WorkerPipeSession.DispatchGatewayEnvelopeAsync"/> must
/// throw <see cref="WorkerFrameProtocolException"/> with
/// <see cref="WorkerFrameProtocolErrorCode.UnexpectedEnvelopeBody"/>
/// when the gateway sends an envelope body that is invalid
/// post-handshake (here a second <c>GatewayHello</c>) and must exit
/// the message loop — <see cref="WorkerPipeSession.RunAsync"/>
/// surfaces the exception to the caller. The message loop does not
/// emit a fault frame on this path (the handshake catch in
/// <c>CompleteStartupHandshakeAsync</c> is what writes faults for
/// pre-handshake protocol violations); the contract this test pins
/// is the exception type/error-code and message-loop exit.
/// </summary>
[Fact]
public async Task RunAsync_WhenGatewaySendsUnexpectedEnvelopeBodyAfterHandshake_ThrowsAndExitsMessageLoop()
{
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(10));
using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token);
FakeRuntimeSession runtime = new();
// Use a long heartbeat interval so no heartbeat frame fires during
// the test window. With no heartbeats and no fault frame written on
// the unexpected-body path, the gateway pipe receives nothing after
// the handshake — no drain task is needed.
WorkerPipeSession session = CreatePipeSession(
pipePair.WorkerStream,
runtime,
new WorkerPipeSessionOptions
{
HeartbeatInterval = TimeSpan.FromSeconds(30),
HeartbeatGrace = TimeSpan.FromSeconds(60),
});
Task runTask = session.RunAsync(cancellation.Token);
await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token);
// Send a second GatewayHello — valid envelope, invalid for the
// post-handshake state, so DispatchGatewayEnvelopeAsync falls to
// the default arm.
await pipePair.GatewayWriter
.WriteAsync(CreateGatewayHelloEnvelope(), cancellation.Token);
WorkerFrameProtocolException exception =
await Assert.ThrowsAsync<WorkerFrameProtocolException>(async () => await runTask);
Assert.Equal(WorkerFrameProtocolErrorCode.UnexpectedEnvelopeBody, exception.ErrorCode);
}
/// <summary>
/// Worker-002 regression: the first heartbeat must be emitted
/// immediately on entering the heartbeat loop, not after a full
@@ -707,6 +859,21 @@ public sealed class WorkerPipeSessionTests
};
}
private static WorkerEnvelope CreateCancelEnvelope(string correlationId)
{
return new WorkerEnvelope
{
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
SessionId = SessionId,
Sequence = 4,
CorrelationId = correlationId,
WorkerCancel = new WorkerCancel
{
Reason = "test-cancel",
},
};
}
private static WorkerEnvelope CreateShutdownEnvelope()
{
return new WorkerEnvelope
@@ -317,81 +317,18 @@ public sealed class AlarmCommandExecutorTests
private static MxAccessCommandExecutor NewExecutor(IAlarmCommandHandler? alarmHandler)
{
// Construct an executor with a no-op data session — we only exercise
// the alarm switch arms, which never touch the data session.
// the alarm switch arms, which never touch the data session. The
// session is built through the internal MxAccessSession.CreateForTesting
// factory (exposed via [assembly: InternalsVisibleTo("MxGateway.Worker.Tests")]
// on MxGateway.Worker), so no reflection is needed.
return new MxAccessCommandExecutor(
session: NoopMxAccessSession.Create(),
session: MxAccessSession.CreateForTesting(
mxAccessServer: new NoopMxAccessServer(),
eventSink: new NoopEventSink()),
variantConverter: new MxGateway.Worker.Conversion.VariantConverter(),
alarmCommandHandler: alarmHandler);
}
/// <summary>
/// Reflection-based helper to construct an MxAccessSession without
/// a real COM object. Only the alarm-side code paths are exercised
/// in this test class, so the session reference is never
/// dereferenced.
/// </summary>
private static class NoopMxAccessSession
{
public static MxAccessSession Create()
{
// Walk to the private constructor via reflection — the public
// factory MxAccessSession.Create(...) requires a real COM object.
// Signature mirrors MxAccessSession's private ctor; the
// MxAccessValueCache slot was added when ReadBulk gained the
// cached-vs-snapshot fork.
System.Reflection.ConstructorInfo? ctor = typeof(MxAccessSession)
.GetConstructor(
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance,
binder: null,
types: new[]
{
typeof(object),
typeof(IMxAccessServer),
typeof(IMxAccessEventSink),
typeof(MxAccessHandleRegistry),
typeof(MxAccessValueCache),
typeof(int),
},
modifiers: null);
if (ctor is null)
{
throw new InvalidOperationException(
"MxAccessSession private ctor signature changed; update the test seam.");
}
return (MxAccessSession)ctor.Invoke(new object[]
{
new object(),
new NullMxAccessServer(),
new NoopEventSink(),
new MxAccessHandleRegistry(),
new MxAccessValueCache(),
System.Environment.CurrentManagedThreadId,
});
}
}
private sealed class NullMxAccessServer : IMxAccessServer
{
public int Register(string clientName) => 0;
public void Unregister(int serverHandle) { }
public int AddItem(int serverHandle, string itemDefinition) => 0;
public int AddItem2(int serverHandle, string itemDefinition, string itemContext) => 0;
public void RemoveItem(int serverHandle, int itemHandle) { }
public void Advise(int serverHandle, int itemHandle) { }
public void UnAdvise(int serverHandle, int itemHandle) { }
public void AdviseSupervisory(int serverHandle, int itemHandle) { }
public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext) => 0;
public void SetBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds) { }
public void Suspend(int serverHandle, int itemHandle) { }
public void Activate(int serverHandle, int itemHandle) { }
public void Write(int serverHandle, int itemHandle, object? value, int userId) { }
public void Write2(int serverHandle, int itemHandle, object? value, object? timestampValue, int userId) { }
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value) { }
public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestampValue) { }
public int AuthenticateUser(string userName, string password) => 0;
public int ArchestrAUserToId(string userName) => 0;
}
private sealed class FakeAlarmHandler : IAlarmCommandHandler
{
public string? LastSubscription { get; private set; }
@@ -39,6 +39,19 @@ public sealed class AlarmCommandHandlerTests
() => handler.Subscribe(@"\\HOST\Galaxy!B", "s1"));
}
/// <summary>
/// Worker.Tests-024: pins both the disposal contract and the
/// origin of the propagated exception. The fake throws
/// <c>InvalidOperationException("simulated wnwrap subscribe failure")</c>
/// from <c>Subscribe</c>; the handler must propagate that exact
/// exception (not swallow it and rethrow its own) and dispose the
/// just-constructed consumer so a retry can build a fresh one.
/// Pinning the message guards against a regression where the
/// handler throws a different <see cref="InvalidOperationException"/>
/// (for example its own "already subscribed" guard) and the
/// disposal assertion alone would still pass while hiding the
/// real swallow.
/// </summary>
[Fact]
public void Subscribe_WhenUnderlyingSubscribeThrows_DisposesConsumer()
{
@@ -47,8 +60,9 @@ public sealed class AlarmCommandHandlerTests
new MxAccessEventQueue(),
() => consumer);
Assert.Throws<InvalidOperationException>(
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
() => handler.Subscribe(@"\\HOST\Galaxy!A", "s1"));
Assert.Contains("simulated wnwrap subscribe failure", exception.Message);
Assert.False(handler.IsSubscribed);
Assert.True(consumer.Disposed);
}
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
using MxGateway.Contracts.Proto;
using MxGateway.Worker.MxAccess;
using MxGateway.Worker.Sta;
using MxGateway.Worker.Tests.TestSupport;
namespace MxGateway.Worker.Tests.MxAccess;
@@ -14,31 +15,18 @@ public sealed class MxAccessLiveComCreationTests
private const string DefaultLiveAddItem2Context = "TestChildObject";
/// <summary>Verifies that StartAsync creates the installed MXAccess COM object on the STA thread when opted in.</summary>
[Fact]
[LiveMxAccessFact]
public async Task StartAsync_WhenOptedIn_CreatesInstalledMxAccessComObjectOnSta()
{
if (!string.Equals(
Environment.GetEnvironmentVariable("MXGATEWAY_RUN_LIVE_MXACCESS_TESTS"),
"1",
StringComparison.Ordinal))
{
return;
}
using MxAccessStaSession session = new();
await session.StartAsync(workerProcessId: 1234);
}
/// <summary>Verifies that Register and Unregister round-trip server handles with installed MXAccess.</summary>
[Fact]
[LiveMxAccessFact]
public async Task RegisterAndUnregister_WhenOptedIn_RoundTripsInstalledMxAccessServerHandle()
{
if (!RunLiveMxAccessTests())
{
return;
}
using MxAccessStaSession session = new();
await session.StartAsync(workerProcessId: 1234);
@@ -73,14 +61,9 @@ public sealed class MxAccessLiveComCreationTests
}
/// <summary>Verifies that AddItem and RemoveItem round-trip item handles with installed MXAccess.</summary>
[Fact]
[LiveMxAccessFact]
public async Task AddItemAndRemoveItem_WhenOptedIn_RoundTripsInstalledMxAccessItemHandle()
{
if (!RunLiveMxAccessTests())
{
return;
}
using MxAccessStaSession session = new();
await session.StartAsync(workerProcessId: 1234);
@@ -146,14 +129,9 @@ public sealed class MxAccessLiveComCreationTests
}
/// <summary>Verifies that AddItem2 and RemoveItem preserve item context with installed MXAccess.</summary>
[Fact]
[LiveMxAccessFact]
public async Task AddItem2AndRemoveItem_WhenOptedIn_PreservesContextForInstalledMxAccess()
{
if (!RunLiveMxAccessTests())
{
return;
}
using MxAccessStaSession session = new();
await session.StartAsync(workerProcessId: 1234);
@@ -220,14 +198,9 @@ public sealed class MxAccessLiveComCreationTests
}
/// <summary>Verifies that Advise and UnAdvise round-trip subscriptions with installed MXAccess.</summary>
[Fact]
[LiveMxAccessFact]
public async Task AdviseAndUnAdvise_WhenOptedIn_RoundTripsInstalledMxAccessSubscription()
{
if (!RunLiveMxAccessTests())
{
return;
}
using MxAccessStaSession session = new();
await session.StartAsync(workerProcessId: 1234);
@@ -341,14 +314,6 @@ public sealed class MxAccessLiveComCreationTests
}
}
private static bool RunLiveMxAccessTests()
{
return string.Equals(
Environment.GetEnvironmentVariable("MXGATEWAY_RUN_LIVE_MXACCESS_TESTS"),
"1",
StringComparison.Ordinal);
}
private static string GetLiveAddItemReference()
{
string itemReference = Environment.GetEnvironmentVariable("MXGATEWAY_LIVE_MXACCESS_ITEM");
@@ -388,6 +388,55 @@ public sealed class MxAccessStaSessionTests
Assert.Equal(typeof(System.Runtime.InteropServices.COMException).FullName, fault.ExceptionType);
}
/// <summary>
/// Worker-016 regression: the alarm poll loop's catch for the graceful
/// STA-runtime-shutdown signal must NOT also swallow a vanilla
/// <see cref="InvalidOperationException"/> raised from inside the marshalled
/// poll lambda — for example the STA-affinity assertion thrown by
/// <c>EnsureOnAlarmConsumerThread</c> if a regression ever caused the poll
/// to run off the alarm-consumer thread. The runtime-shutdown signal is now
/// the dedicated <see cref="StaRuntimeShutdownException"/>; a plain
/// <see cref="InvalidOperationException"/> from <c>PollOnce</c> must reach
/// the fault-recording arm and become observable on the event queue.
/// </summary>
[Fact]
public async Task RunAlarmPollLoop_WhenPollOnceThrowsInvalidOperation_RecordsFaultOnEventQueue()
{
FakeAlarmCommandHandler handler = new()
{
PollException = new InvalidOperationException(
"Alarm consumer accessed off its owning STA thread."),
};
FakeMxAccessComObjectFactory factory = new();
FakeMxAccessEventSink eventSink = new();
using StaRuntime runtime = CreateRuntime();
MxAccessEventQueue eventQueue = new();
using MxAccessStaSession session = new(
runtime,
factory,
eventSink,
eventQueue,
_eq => handler);
await session.StartAsync("session-1", workerProcessId: 1);
using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!eventQueue.IsFaulted && !timeout.IsCancellationRequested)
{
await Task.Delay(50, CancellationToken.None);
}
Assert.True(
eventQueue.IsFaulted,
"Expected the alarm poll InvalidOperationException to fault the event queue, "
+ "not be silently swallowed as a shutdown signal.");
WorkerFault? fault = session.DrainFault();
Assert.NotNull(fault);
Assert.Equal(WorkerFaultCategory.MxaccessEventConversionFailed, fault!.Category);
Assert.Equal(typeof(InvalidOperationException).FullName, fault.ExceptionType);
Assert.Contains("alarm poll failed", fault.DiagnosticMessage, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Worker-008 regression: the STA-affinity guard throws when an
/// IMxAccessAlarmConsumer call is attempted off the thread that created
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Google.Protobuf.WellKnownTypes;
@@ -84,27 +83,47 @@ public sealed class MxAccessValueCacheTests
Assert.Equal(2UL, cache.CurrentVersion(7, 21));
}
/// <summary>
/// Worker.Tests-020: pins the contract that <c>TryWaitForUpdate</c>
/// returns <c>false</c> when the deadline has elapsed with no
/// <c>Set</c>, yields a default <c>CachedValue</c>, and invokes
/// <c>pumpStep</c> at least once so MXAccess Windows messages can
/// be dispatched. Earlier revisions of this test asserted both an
/// elapsed-time floor (<c>stopwatch.ElapsedMilliseconds &gt;= 60</c>)
/// and <c>pumpCalls &gt; 1</c> — the same wall-clock-floor race
/// pattern Worker.Tests-003/004/013 corrected. To eliminate the
/// timing dependency entirely (the equivalent of a manual time
/// source for a <c>DateTime.UtcNow</c>-based deadline), the test
/// now supplies a deadline already in the past: the loop pumps
/// once, observes the passed deadline, and returns false
/// deterministically without any <c>Thread.Sleep</c>. The
/// deadline-honouring contract is what this test exists to pin;
/// elapsed time and pump-iteration count are incidental.
/// </summary>
[Fact]
public void TryWaitForUpdate_ReturnsFalseAfterDeadline_WhenNoSetOccurs()
{
MxAccessValueCache cache = new();
int pumpCalls = 0;
Stopwatch stopwatch = Stopwatch.StartNew();
// Deadline already in the past — eliminates the wall-clock-floor
// race. The loop must pump once (so MXAccess messages can dispatch
// on the calling thread even when the deadline has just expired)
// and then immediately observe the passed deadline.
DateTime expiredDeadlineUtc = DateTime.UtcNow.AddMilliseconds(-1);
bool result = cache.TryWaitForUpdate(
serverHandle: 7,
itemHandle: 21,
sinceVersion: 0,
deadlineUtc: DateTime.UtcNow.AddMilliseconds(80),
deadlineUtc: expiredDeadlineUtc,
pumpStep: () => Interlocked.Increment(ref pumpCalls),
out MxAccessValueCache.CachedValue value,
pollIntervalMs: 5);
stopwatch.Stop();
Assert.False(result);
Assert.Equal(default, value.Value);
Assert.True(pumpCalls > 1, $"pumpCalls={pumpCalls}: pump step should fire each poll iteration so MXAccess events can dispatch.");
Assert.True(stopwatch.ElapsedMilliseconds >= 60, $"elapsed={stopwatch.ElapsedMilliseconds}ms: wait should approximate the deadline.");
Assert.Equal(1, pumpCalls);
}
[Fact]
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
@@ -149,4 +150,171 @@ public sealed class WnWrapAlarmConsumerXmlTests
&& parameter.Name.IndexOf("poll", StringComparison.OrdinalIgnoreCase) >= 0);
}
}
/// <summary>
/// Worker.Tests-022: pins the "new alarm sighting" branch of
/// <see cref="WnWrapAlarmConsumer.ComputeTransitions"/>. A GUID
/// that appears in <c>next</c> but not in <c>previous</c> must
/// produce exactly one transition with
/// <see cref="MxAlarmStateKind.Unspecified"/> as the previous
/// state — the proto layer relies on this sentinel to map a
/// first sighting to a <c>Raise</c>.
/// </summary>
[Fact]
public void ComputeTransitions_WhenAlarmIsNewInNextSnapshot_EmitsTransitionWithUnspecifiedPreviousState()
{
Guid alarmGuid = new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73");
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new();
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
{
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
};
IReadOnlyList<MxAlarmTransitionEvent> transitions =
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
MxAlarmTransitionEvent single = Assert.Single(transitions);
Assert.Equal(alarmGuid, single.Record.AlarmGuid);
Assert.Equal(MxAlarmStateKind.UnackAlm, single.Record.State);
Assert.Equal(MxAlarmStateKind.Unspecified, single.PreviousState);
}
/// <summary>
/// Worker.Tests-022: pins the "state unchanged" branch. A GUID
/// present in both snapshots with identical
/// <see cref="MxAlarmSnapshotRecord.State"/> must produce no
/// transition — a regression that emits a transition every poll
/// regardless of state change would slip through without this
/// test.
/// </summary>
[Fact]
public void ComputeTransitions_WhenAlarmStateUnchanged_EmitsNoTransition()
{
Guid alarmGuid = Guid.NewGuid();
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
{
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
};
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
{
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
};
IReadOnlyList<MxAlarmTransitionEvent> transitions =
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
Assert.Empty(transitions);
}
/// <summary>
/// Worker.Tests-022: pins the "state changed" branch. A GUID
/// present in both snapshots with a different state must produce
/// one transition carrying the prior state so the proto layer
/// can distinguish e.g. <c>UnackAlm</c>→<c>AckAlm</c>
/// (Acknowledge) from <c>Unspecified</c>→<c>UnackAlm</c> (Raise).
/// </summary>
[Fact]
public void ComputeTransitions_WhenAlarmStateChanged_EmitsTransitionWithPriorState()
{
Guid alarmGuid = Guid.NewGuid();
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
{
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
};
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
{
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.AckAlm),
};
IReadOnlyList<MxAlarmTransitionEvent> transitions =
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
MxAlarmTransitionEvent single = Assert.Single(transitions);
Assert.Equal(alarmGuid, single.Record.AlarmGuid);
Assert.Equal(MxAlarmStateKind.AckAlm, single.Record.State);
Assert.Equal(MxAlarmStateKind.UnackAlm, single.PreviousState);
}
/// <summary>
/// Worker.Tests-022: pins the "alarm cleared from the active set"
/// branch. AVEVA drops cleared alarms from
/// <c>GetXmlCurrentAlarms2</c>'s active set rather than emitting a
/// transition record. A GUID present in
/// <c>previous</c> but absent from <c>next</c> must therefore
/// produce no transition; the diff treats disappearance as an
/// implicit clear that the proto layer recognises by the missing
/// GUID, not by an emitted event.
/// </summary>
[Fact]
public void ComputeTransitions_WhenAlarmDroppedFromActiveSet_EmitsNoTransition()
{
Guid alarmGuid = Guid.NewGuid();
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
{
[alarmGuid] = NewRecord(alarmGuid, MxAlarmStateKind.UnackAlm),
};
Dictionary<Guid, MxAlarmSnapshotRecord> next = new();
IReadOnlyList<MxAlarmTransitionEvent> transitions =
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
Assert.Empty(transitions);
}
/// <summary>
/// Worker.Tests-022: pins the multi-alarm fan-out. Multiple
/// simultaneous transitions (new + changed + unchanged + dropped)
/// in one snapshot must produce exactly the changed and new
/// entries — not the unchanged and not the dropped.
/// </summary>
[Fact]
public void ComputeTransitions_WithMixedDelta_EmitsOnlyNewAndChangedTransitions()
{
Guid newGuid = Guid.NewGuid();
Guid changedGuid = Guid.NewGuid();
Guid unchangedGuid = Guid.NewGuid();
Guid droppedGuid = Guid.NewGuid();
Dictionary<Guid, MxAlarmSnapshotRecord> previous = new()
{
[changedGuid] = NewRecord(changedGuid, MxAlarmStateKind.UnackAlm),
[unchangedGuid] = NewRecord(unchangedGuid, MxAlarmStateKind.AckAlm),
[droppedGuid] = NewRecord(droppedGuid, MxAlarmStateKind.UnackAlm),
};
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
{
[newGuid] = NewRecord(newGuid, MxAlarmStateKind.UnackAlm),
[changedGuid] = NewRecord(changedGuid, MxAlarmStateKind.AckAlm),
[unchangedGuid] = NewRecord(unchangedGuid, MxAlarmStateKind.AckAlm),
};
IReadOnlyList<MxAlarmTransitionEvent> transitions =
WnWrapAlarmConsumer.ComputeTransitions(previous, next);
Assert.Equal(2, transitions.Count);
MxAlarmTransitionEvent newTransition = Assert.Single(
transitions,
t => t.Record.AlarmGuid == newGuid);
Assert.Equal(MxAlarmStateKind.Unspecified, newTransition.PreviousState);
Assert.Equal(MxAlarmStateKind.UnackAlm, newTransition.Record.State);
MxAlarmTransitionEvent changedTransition = Assert.Single(
transitions,
t => t.Record.AlarmGuid == changedGuid);
Assert.Equal(MxAlarmStateKind.UnackAlm, changedTransition.PreviousState);
Assert.Equal(MxAlarmStateKind.AckAlm, changedTransition.Record.State);
}
private static MxAlarmSnapshotRecord NewRecord(Guid guid, MxAlarmStateKind state)
{
return new MxAlarmSnapshotRecord
{
AlarmGuid = guid,
State = state,
TagName = "TestMachine.TestAlarm",
ProviderNode = "TEST-NODE",
ProviderName = "Galaxy",
};
}
}
@@ -141,7 +141,7 @@ public sealed class AlarmClientWmProbeTests : IDisposable
}
[Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture alarm-path behavior")]
public void ProbeAlarmClientWmMessages()
public void ProbeAlarmClient_OnDevRig_LogsAlarmWindowMessages()
{
// 1. Pre-resolve a few candidate RegisterWindowMessage strings so any
// matches in the captured log can be labeled. None of these is
@@ -42,7 +42,7 @@ public sealed class AlarmsLiveSmokeTests
}
[Fact(Skip = "Live dev-rig smoke test — flip Skip=null with AVEVA + the alarm flip script running. Verified working 2026-05-01.")]
public void Alarms_full_pipeline_round_trip()
public void Alarms_FullPipelineRoundTrip_RaisesAndAcknowledges()
{
Exception? threadException = null;
var done = new ManualResetEventSlim(false);
@@ -52,7 +52,7 @@ public sealed class WnWrapConsumerProbeTests
}
[Fact(Skip = "Runtime probe — flip Skip=null on the dev rig (AVEVA installed) to capture wnwrapConsumer XML alarm output. Verified working 2026-05-01.")]
public void ProbeWnWrapConsumer()
public void ProbeWnWrapConsumer_OnDevRig_LogsXmlAlarmStream()
{
Exception? threadException = null;
var done = new ManualResetEventSlim(false);
@@ -99,7 +99,16 @@ public sealed class StaRuntimeTests
Assert.Equal(runtime.StaThreadId, threadId);
}
/// <summary>Verifies that InvokeAsync returns a faulted task when called after Shutdown.</summary>
/// <summary>
/// Verifies that InvokeAsync returns a faulted task when called after
/// Shutdown. Worker-016 introduced <see cref="StaRuntimeShutdownException"/>
/// (a dedicated subtype of <see cref="InvalidOperationException"/>) so
/// callers — notably <c>MxAccessStaSession.RunAlarmPollLoopAsync</c> —
/// can distinguish the graceful shutdown signal from a vanilla
/// <see cref="InvalidOperationException"/> such as an STA-affinity
/// assertion. The test pins the exact type so a regression that
/// reverts to a plain <c>InvalidOperationException</c> fails here.
/// </summary>
[Fact]
public async Task InvokeAsync_AfterShutdown_ReturnsFaultedTask()
{
@@ -108,7 +117,7 @@ public sealed class StaRuntimeTests
runtime.Start();
runtime.Shutdown(TimeSpan.FromSeconds(2));
InvalidOperationException exception = await Assert.ThrowsAsync<InvalidOperationException>(
StaRuntimeShutdownException exception = await Assert.ThrowsAsync<StaRuntimeShutdownException>(
() => runtime.InvokeAsync(() => Thread.CurrentThread.ManagedThreadId));
Assert.Contains("shutting down", exception.Message);
@@ -23,6 +23,7 @@ internal sealed class FakeRuntimeSession : IWorkerRuntimeSession
private readonly ManualResetEventSlim releaseDispatch = new(false);
private readonly object gate = new();
private readonly Queue<WorkerEvent> events = new();
private readonly List<string> cancelledCorrelationIds = new();
private WorkerRuntimeHeartbeatSnapshot snapshot = new(
DateTimeOffset.UtcNow,
pendingCommandCount: 0,
@@ -148,12 +149,41 @@ internal sealed class FakeRuntimeSession : IWorkerRuntimeSession
return null;
}
/// <summary>
/// Gets a snapshot of every correlation id passed to
/// <see cref="CancelCommand"/>. Recording lets the IPC tests
/// assert that a <c>WorkerCancel</c> envelope dispatched on the
/// gateway side reaches the runtime session — see Worker.Tests-017.
/// </summary>
public IReadOnlyList<string> CancelledCorrelationIds
{
get
{
lock (gate)
{
return new List<string>(cancelledCorrelationIds);
}
}
}
/// <summary>
/// Optional return value yielded by <see cref="CancelCommand"/>.
/// Defaults to <c>false</c> (the runtime had no matching in-flight
/// command), matching the previous test-double behaviour.
/// </summary>
public bool CancelCommandReturnValue { get; set; }
/// <summary>Cancels command by correlation ID.</summary>
/// <param name="correlationId">The command correlation ID.</param>
/// <returns>True if cancelled; false otherwise.</returns>
public bool CancelCommand(string correlationId)
{
return false;
lock (gate)
{
cancelledCorrelationIds.Add(correlationId);
}
return CancelCommandReturnValue;
}
/// <summary>Requests graceful shutdown.</summary>
@@ -0,0 +1,36 @@
using System;
namespace MxGateway.Worker.Tests.TestSupport;
/// <summary>
/// Marks an xUnit test as requiring installed MXAccess COM and live
/// provider state. When the opt-in environment variable
/// <c>MXGATEWAY_RUN_LIVE_MXACCESS_TESTS</c> is not set to <c>1</c>, the
/// test is reported as <c>Skipped</c> by xUnit rather than silently
/// returning early (which xUnit would otherwise report as
/// <c>Passed</c>). Mirrors
/// <c>MxGateway.IntegrationTests.LiveMxAccessFactAttribute</c>; the
/// copy avoids a cross-project reference and keeps the Worker.Tests
/// net48/x86 build self-contained.
/// </summary>
public sealed class LiveMxAccessFactAttribute : FactAttribute
{
/// <summary>
/// The environment variable that opts the suite into running live
/// MXAccess COM tests. Must be set to <c>1</c> on a machine with the
/// installed MXAccess runtime and a reachable Galaxy provider.
/// </summary>
public const string LiveMxAccessVariableName = "MXGATEWAY_RUN_LIVE_MXACCESS_TESTS";
/// <summary>Initializes the attribute, skipping the test unless the env var is set.</summary>
public LiveMxAccessFactAttribute()
{
if (!string.Equals(
Environment.GetEnvironmentVariable(LiveMxAccessVariableName),
"1",
StringComparison.Ordinal))
{
Skip = $"Set {LiveMxAccessVariableName}=1 to run live MXAccess tests.";
}
}
}
@@ -0,0 +1,92 @@
using MxGateway.Worker.MxAccess;
namespace MxGateway.Worker.Tests.TestSupport;
/// <summary>
/// Shared no-operation <see cref="IMxAccessServer"/> for tests that need to
/// construct an <see cref="MxAccessSession"/> via
/// <see cref="MxAccessSession.CreateForTesting"/> but do not exercise any
/// MXAccess COM call. Replaces the per-file <c>NullMxAccessServer</c> copy
/// that previously lived inside <c>AlarmCommandExecutorTests</c> and was
/// constructed via reflection — see Worker.Tests-016 for the rationale.
/// </summary>
internal sealed class NoopMxAccessServer : IMxAccessServer
{
/// <inheritdoc />
public int Register(string clientName) => 0;
/// <inheritdoc />
public void Unregister(int serverHandle)
{
}
/// <inheritdoc />
public int AddItem(int serverHandle, string itemDefinition) => 0;
/// <inheritdoc />
public int AddItem2(int serverHandle, string itemDefinition, string itemContext) => 0;
/// <inheritdoc />
public void RemoveItem(int serverHandle, int itemHandle)
{
}
/// <inheritdoc />
public void Advise(int serverHandle, int itemHandle)
{
}
/// <inheritdoc />
public void UnAdvise(int serverHandle, int itemHandle)
{
}
/// <inheritdoc />
public void AdviseSupervisory(int serverHandle, int itemHandle)
{
}
/// <inheritdoc />
public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext) => 0;
/// <inheritdoc />
public void SetBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds)
{
}
/// <inheritdoc />
public void Suspend(int serverHandle, int itemHandle)
{
}
/// <inheritdoc />
public void Activate(int serverHandle, int itemHandle)
{
}
/// <inheritdoc />
public void Write(int serverHandle, int itemHandle, object? value, int userId)
{
}
/// <inheritdoc />
public void Write2(int serverHandle, int itemHandle, object? value, object? timestampValue, int userId)
{
}
/// <inheritdoc />
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value)
{
}
/// <inheritdoc />
public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestampValue)
{
}
/// <inheritdoc />
public int AuthenticateUser(string userName, string password) => 0;
/// <inheritdoc />
public int ArchestrAUserToId(string userName) => 0;
}
+54 -5
View File
@@ -402,7 +402,15 @@ public sealed class WorkerPipeSession
try
{
MxCommandReply reply = await runtimeSession.DispatchAsync(staCommand).ConfigureAwait(false);
if (_state is not WorkerState.Ready and not WorkerState.ExecutingCommand)
// _state is only ever assigned Starting, Handshaking, InitializingSta,
// Ready, ShuttingDown, Faulted, or Stopped — never ExecutingCommand
// (that value is synthesized in CreateHeartbeat from the live
// CurrentCommandCorrelationId and never written back to _state). So
// the only command-serving state is Ready; anything else means a
// state transition (shutdown / fault) raced the command's
// completion and we must drop the reply rather than write into a
// half-torn-down pipe.
if (_state != WorkerState.Ready)
{
LogCommandResultDropped(envelope.CorrelationId, staCommand.MethodName);
return;
@@ -420,7 +428,7 @@ public sealed class WorkerPipeSession
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
if (_state is not WorkerState.Ready and not WorkerState.ExecutingCommand)
if (_state != WorkerState.Ready)
{
LogCommandResultDropped(envelope.CorrelationId, staCommand.MethodName);
return;
@@ -599,6 +607,24 @@ public sealed class WorkerPipeSession
}
}
/// <summary>
/// The watchdog detects a hung STA (no thread activity for longer than
/// <c>HeartbeatGrace</c>) and emits an <c>StaHung</c> fault. Design
/// intent: catch a stuck STA thread, not a legitimately long-running
/// command. <c>StaRuntime.ProcessQueuedCommands</c> calls
/// <c>MarkActivity()</c> only immediately before and after
/// <c>workItem.Execute()</c>, so a synchronously long-running STA
/// command (e.g. <c>ReadBulk</c> waiting <c>timeout_ms</c> for the
/// first OnDataChange callback) freezes <c>LastActivityUtc</c> for the
/// duration of the wait even though the worker is healthy. To avoid
/// self-faulting a healthy in-flight command (Worker-017), the
/// watchdog is suppressed while <c>CurrentCommandCorrelationId</c> is
/// non-empty — the worker already advertises the in-flight command on
/// each heartbeat, so the gateway has the signal it needs to decide
/// the command is just slow. The watchdog still fires on a truly hung
/// STA (no command in flight and no activity), which is the only case
/// the watchdog can usefully distinguish from a slow command.
/// </summary>
private async Task ReportWatchdogFaultIfNeededAsync(
WorkerRuntimeHeartbeatSnapshot snapshot,
CancellationToken cancellationToken)
@@ -610,6 +636,17 @@ public sealed class WorkerPipeSession
return;
}
if (!string.IsNullOrEmpty(snapshot.CurrentCommandCorrelationId))
{
// A command is in flight — the STA is busy executing it, not
// hung. The next MarkActivity() in StaRuntime.ProcessQueuedCommands
// will refresh LastActivityUtc once the command returns, at which
// point this branch stops being taken. The heartbeat already
// surfaces the in-flight correlation id so the gateway can apply
// its own per-command timeout if it considers the command too slow.
return;
}
if (_watchdogFaultSent)
{
return;
@@ -789,16 +826,28 @@ public sealed class WorkerPipeSession
private async Task<WorkerReady> InitializeMxAccessAsync(CancellationToken cancellationToken)
{
_runtimeSession = new MxAccessStaSession(eq => new AlarmCommandHandler(eq));
// RunAsync constructs the runtime session via _runtimeSessionFactory()
// before invoking CompleteStartupHandshakeAsync, so on the production
// path _runtimeSession is already non-null when this default
// initializer runs. Treat that pre-existing instance as authoritative
// and only drive its StartAsync — unconditionally reassigning
// _runtimeSession here would leak the factory-supplied session (no
// Dispose) and replace it with a hard-coded MxAccessStaSession,
// discarding the factory's configuration. The fall-back construction
// is preserved for the legacy direct-invocation path where the
// parameterless CompleteStartupHandshakeAsync is used without a
// prior factory call.
_runtimeSession ??= new MxAccessStaSession(eq => new AlarmCommandHandler(eq));
IWorkerRuntimeSession session = _runtimeSession;
try
{
return await _runtimeSession
return await session
.StartAsync(_options.SessionId, _processIdProvider(), cancellationToken)
.ConfigureAwait(false);
}
catch
{
_runtimeSession.Dispose();
session.Dispose();
_runtimeSession = null;
throw;
}
@@ -58,6 +58,35 @@ public sealed class MxAccessSession : IDisposable
};
}
/// <summary>
/// Test-only seam: constructs a session that bypasses the live COM
/// factory. The caller supplies the <see cref="IMxAccessServer"/> and
/// <see cref="IMxAccessEventSink"/> directly so tests can exercise
/// session methods without touching MXAccess COM. This is exposed via
/// <c>InternalsVisibleTo("MxGateway.Worker.Tests")</c>; production code
/// must use the <see cref="Create"/> factory.
/// </summary>
/// <param name="mxAccessServer">The server abstraction to drive.</param>
/// <param name="eventSink">The event sink to attach to the session.</param>
/// <param name="handleRegistry">Optional handle registry; a fresh one is created when null.</param>
/// <param name="valueCache">Optional value cache; a fresh one is created when null.</param>
/// <param name="creationThreadId">Optional creation thread id; defaults to the current managed thread id.</param>
internal static MxAccessSession CreateForTesting(
IMxAccessServer mxAccessServer,
IMxAccessEventSink eventSink,
MxAccessHandleRegistry? handleRegistry = null,
MxAccessValueCache? valueCache = null,
int? creationThreadId = null)
{
return new MxAccessSession(
new object(),
mxAccessServer,
eventSink,
handleRegistry ?? new MxAccessHandleRegistry(),
valueCache ?? new MxAccessValueCache(),
creationThreadId ?? Environment.CurrentManagedThreadId);
}
/// <summary>Creates and initializes an MXAccess COM session.</summary>
/// <param name="factory">Factory to create the MXAccess COM object.</param>
/// <param name="eventSink">Event sink to attach to the COM object.</param>
@@ -258,19 +258,31 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
// STA runtime or alarm handler disposed — stop the loop gracefully.
return;
}
catch (InvalidOperationException)
catch (StaRuntimeShutdownException)
{
// STA runtime shutting down — stop the loop gracefully.
// The dedicated shutdown type lets us distinguish this
// graceful-stop signal from the STA-affinity assertion
// raised by EnsureOnAlarmConsumerThread (Worker-008),
// which is also an InvalidOperationException but signals
// a programming-error regression — that case falls through
// to the generic Exception arm below and is recorded as a
// fault on the event queue, so an affinity regression
// becomes observable on the IPC fault path instead of
// silently stopping alarm delivery.
return;
}
catch (Exception exception)
{
// A real alarm-poll failure (COMException from
// GetXmlCurrentAlarms2, malformed-XML parse failure, etc.).
// Record it as a fault on the event queue so a broken
// alarm subscription becomes observable on the IPC fault
// path instead of silently faulting this never-awaited
// task. The loop then stops — the subscription is dead.
// GetXmlCurrentAlarms2, malformed-XML parse failure, an
// STA-affinity InvalidOperationException from
// EnsureOnAlarmConsumerThread, etc.). Record it as a
// fault on the event queue so a broken alarm subscription
// — or an affinity-invariant regression — becomes
// observable on the IPC fault path instead of silently
// faulting this never-awaited task. The loop then stops —
// the subscription is dead.
eventQueue.RecordFault(CreateAlarmPollFault(exception));
return;
}
@@ -2,22 +2,6 @@ using System;
namespace MxGateway.Worker.MxAccess;
/// <summary>
/// Library-agnostic alarm-state enum. Mirrors the four <c>STATE</c>
/// values returned by AVEVA's <c>WNWRAPCONSUMERLib</c> XML payload —
/// <c>UNACK_ALM</c>, <c>ACK_ALM</c>, <c>UNACK_RTN</c>, <c>ACK_RTN</c>.
/// Decoupling the consumer from any specific COM library keeps the
/// proto-build path testable without an AVEVA install.
/// </summary>
public enum MxAlarmStateKind
{
Unspecified = 0,
UnackAlm = 1,
AckAlm = 2,
UnackRtn = 3,
AckRtn = 4,
}
/// <summary>
/// Single alarm record as emitted by the wnwrapConsumer XML stream.
/// Field names match the captured XML schema (see
@@ -40,20 +24,3 @@ public sealed class MxAlarmSnapshotRecord
public string OperatorName { get; set; } = string.Empty;
public string AlarmComment { get; set; } = string.Empty;
}
/// <summary>
/// One transition emitted by the consumer's snapshot diff. Pairs the
/// latest record with its previous state so the proto layer can decide
/// whether the transition is a Raise / Acknowledge / Clear.
/// </summary>
public sealed class MxAlarmTransitionEvent : EventArgs
{
public MxAlarmSnapshotRecord Record { get; set; } = new MxAlarmSnapshotRecord();
/// <summary>
/// The state on the consumer's previous polled snapshot, or
/// <see cref="MxAlarmStateKind.Unspecified"/> when this is the
/// first time the GUID has been observed.
/// </summary>
public MxAlarmStateKind PreviousState { get; set; }
}
@@ -0,0 +1,17 @@
namespace MxGateway.Worker.MxAccess;
/// <summary>
/// Library-agnostic alarm-state enum. Mirrors the four <c>STATE</c>
/// values returned by AVEVA's <c>WNWRAPCONSUMERLib</c> XML payload —
/// <c>UNACK_ALM</c>, <c>ACK_ALM</c>, <c>UNACK_RTN</c>, <c>ACK_RTN</c>.
/// Decoupling the consumer from any specific COM library keeps the
/// proto-build path testable without an AVEVA install.
/// </summary>
public enum MxAlarmStateKind
{
Unspecified = 0,
UnackAlm = 1,
AckAlm = 2,
UnackRtn = 3,
AckRtn = 4,
}
@@ -0,0 +1,20 @@
using System;
namespace MxGateway.Worker.MxAccess;
/// <summary>
/// One transition emitted by the consumer's snapshot diff. Pairs the
/// latest record with its previous state so the proto layer can decide
/// whether the transition is a Raise / Acknowledge / Clear.
/// </summary>
public sealed class MxAlarmTransitionEvent : EventArgs
{
public MxAlarmSnapshotRecord Record { get; set; } = new MxAlarmSnapshotRecord();
/// <summary>
/// The state on the consumer's previous polled snapshot, or
/// <see cref="MxAlarmStateKind.Unspecified"/> when this is the
/// first time the GUID has been observed.
/// </summary>
public MxAlarmStateKind PreviousState { get; set; }
}
@@ -56,7 +56,6 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
private wwAlarmConsumerClass? client;
private wwAlarmConsumerClass? ackClient;
private string subscriptionExpression = string.Empty;
private bool subscribed;
private bool disposed;
@@ -157,8 +156,28 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
// also breaks AlarmAckByName on the same consumer (rejects with
// -55), so a separate ack-only consumer is provisioned below
// that gets only Initialize/Register/Subscribe (no SetXmlAlarmQuery).
//
// The wnwrap interop signature is `void SetXmlAlarmQuery(string)`
// — there is no integer return code to gate on like the other v1
// lifecycle calls in this method. A genuine failure surfaces as a
// COM exception (mapped from the underlying HRESULT). Wrap the
// call so a failure becomes an InvalidOperationException with
// diagnostic context, matching the other call-gates' failure
// shape rather than letting an opaque COMException escape with
// no indication that the alarm subscription is now misconfigured
// and the next GetXmlCurrentAlarms2 poll will fail with E_FAIL.
string xmlQuery = ComposeXmlAlarmQuery(subscription);
com.SetXmlAlarmQuery(xmlQuery);
try
{
com.SetXmlAlarmQuery(xmlQuery);
}
catch (COMException ex)
{
throw new InvalidOperationException(
$"wwAlarmConsumer.SetXmlAlarmQuery failed with HRESULT 0x{ex.HResult:X8}; " +
"subsequent GetXmlCurrentAlarms2 polls would return E_FAIL.",
ex);
}
// Provision a parallel COM consumer for ack calls. It runs the
// v1 lifecycle (Initialize/Register/Subscribe) only; without
@@ -185,7 +204,6 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
$"Ack consumer setup returned non-zero status: " +
$"Initialize={ackInit}, Register={ackReg}, Subscribe={ackSub}.");
}
subscriptionExpression = subscription;
subscribed = true;
}
@@ -303,23 +321,10 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
Dictionary<Guid, MxAlarmSnapshotRecord> next = ParseSnapshotXml(xml);
List<MxAlarmTransitionEvent> transitions = new List<MxAlarmTransitionEvent>();
IReadOnlyList<MxAlarmTransitionEvent> transitions;
lock (syncRoot)
{
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
{
MxAlarmStateKind previousState = MxAlarmStateKind.Unspecified;
if (latestSnapshot.TryGetValue(kv.Key, out MxAlarmSnapshotRecord? prev))
{
previousState = prev.State;
if (previousState == kv.Value.State) continue; // no transition
}
transitions.Add(new MxAlarmTransitionEvent
{
Record = kv.Value,
PreviousState = previousState,
});
}
transitions = ComputeTransitions(latestSnapshot, next);
latestSnapshot.Clear();
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
{
@@ -336,6 +341,52 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
}
}
/// <summary>
/// Pure snapshot-to-transitions diff. Compares the previous polled
/// snapshot to the next snapshot and produces one
/// <see cref="MxAlarmTransitionEvent"/> per state change. Used by
/// <see cref="PollOnce"/> after a successful
/// <c>GetXmlCurrentAlarms2</c> call; exposed as <c>internal static</c>
/// so the diff rules can be unit-tested without driving the
/// wnwrapConsumer COM object (Worker.Tests-022).
/// </summary>
/// <remarks>
/// <para>Rules:</para>
/// <list type="bullet">
/// <item><description>A GUID present in <paramref name="next"/> but not in <paramref name="previous"/> produces a transition with <see cref="MxAlarmStateKind.Unspecified"/> as the previous state — first sighting.</description></item>
/// <item><description>A GUID present in both with the same <see cref="MxAlarmSnapshotRecord.State"/> produces no transition.</description></item>
/// <item><description>A GUID present in both with a different <see cref="MxAlarmSnapshotRecord.State"/> produces a transition carrying the prior state.</description></item>
/// <item><description>A GUID present in <paramref name="previous"/> but absent from <paramref name="next"/> produces no transition. AVEVA drops cleared alarms from the active set; the snapshot simply stops mentioning them.</description></item>
/// </list>
/// </remarks>
/// <param name="previous">The snapshot from the previous poll (or empty on first call).</param>
/// <param name="next">The snapshot just parsed from <c>GetXmlCurrentAlarms2</c>.</param>
/// <returns>One transition per state change in <paramref name="next"/>.</returns>
internal static IReadOnlyList<MxAlarmTransitionEvent> ComputeTransitions(
Dictionary<Guid, MxAlarmSnapshotRecord> previous,
Dictionary<Guid, MxAlarmSnapshotRecord> next)
{
if (previous is null) throw new ArgumentNullException(nameof(previous));
if (next is null) throw new ArgumentNullException(nameof(next));
List<MxAlarmTransitionEvent> transitions = new List<MxAlarmTransitionEvent>();
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
{
MxAlarmStateKind previousState = MxAlarmStateKind.Unspecified;
if (previous.TryGetValue(kv.Key, out MxAlarmSnapshotRecord? prev))
{
previousState = prev.State;
if (previousState == kv.Value.State) continue; // no transition
}
transitions.Add(new MxAlarmTransitionEvent
{
Record = kv.Value,
PreviousState = previousState,
});
}
return transitions;
}
/// <summary>
/// Parse the XML payload returned by <c>GetXmlCurrentAlarms2</c>
/// into a GUID-keyed dictionary. Records with malformed GUIDs are
+2 -3
View File
@@ -99,7 +99,7 @@ public sealed class StaRuntime : IDisposable
{
if (shutdownRequested)
{
throw new InvalidOperationException("The worker STA runtime is shutting down.");
throw new StaRuntimeShutdownException();
}
if (!startRequested)
@@ -167,8 +167,7 @@ public sealed class StaRuntime : IDisposable
{
if (shutdownRequested)
{
return Task.FromException<T>(
new InvalidOperationException("The worker STA runtime is shutting down."));
return Task.FromException<T>(new StaRuntimeShutdownException());
}
commandQueue.Enqueue(workItem);
@@ -0,0 +1,35 @@
using System;
namespace MxGateway.Worker.Sta;
/// <summary>
/// Thrown by <see cref="StaRuntime"/> when an operation is rejected because
/// the runtime is shutting down (or has already shut down). The dedicated
/// type lets callers distinguish a graceful shutdown signal — which should
/// stop their work loops without recording a fault — from a genuine
/// programming-error <see cref="InvalidOperationException"/> such as the
/// STA-affinity assertion in <c>MxAccessStaSession.AssertOnAlarmConsumerThread</c>.
/// It inherits from <see cref="InvalidOperationException"/> so existing
/// callers that catch the latter remain source-compatible.
/// </summary>
public sealed class StaRuntimeShutdownException : InvalidOperationException
{
/// <summary>
/// Initializes a new instance of <see cref="StaRuntimeShutdownException"/>
/// with a default message.
/// </summary>
public StaRuntimeShutdownException()
: base("The worker STA runtime is shutting down.")
{
}
/// <summary>
/// Initializes a new instance of <see cref="StaRuntimeShutdownException"/>
/// with the specified message.
/// </summary>
/// <param name="message">Diagnostic message.</param>
public StaRuntimeShutdownException(string message)
: base(message)
{
}
}