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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
+88
@@ -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 >= 60</c>)
|
||||
/// and <c>pumpCalls > 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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);
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user