Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e4ba11a9f | |||
| ff86b3f0b0 | |||
| 653f17c669 | |||
| 556c3bfa83 | |||
| 9b3637257c | |||
| 77eac95f33 | |||
| 015fa1f50d | |||
| dede407304 | |||
| 0d96963c99 | |||
| 3661420f0a | |||
| 14419853c7 | |||
| a20517f5ad | |||
| 626e7762d9 | |||
| 8d6d3f6188 | |||
| 276288ad87 | |||
| 76bd3de5a2 | |||
| 29455fc1f6 | |||
| 5511609880 | |||
| 451dccf7e3 | |||
| cde9c89386 | |||
| d496f1fd75 |
@@ -257,19 +257,18 @@ Do not show API key secrets or pepper values.
|
|||||||
|
|
||||||
## Authentication And Authorization
|
## Authentication And Authorization
|
||||||
|
|
||||||
Dashboard access should use the same API-key authentication model as gRPC where
|
Dashboard access uses the same API-key authentication model as gRPC where
|
||||||
practical.
|
practical.
|
||||||
|
|
||||||
Recommended v1 behavior:
|
Implemented v1 behavior:
|
||||||
|
|
||||||
- dashboard disabled by default unless configured,
|
|
||||||
- when enabled, require API key auth,
|
- when enabled, require API key auth,
|
||||||
- require `admin` scope for dashboard access,
|
- require `admin` scope for dashboard access,
|
||||||
- accept API key through a secure cookie established by a simple login form, or
|
- accept API key through a secure cookie established by a simple login form,
|
||||||
through reverse-proxy/header configuration for local deployments,
|
- do not put API keys in query strings,
|
||||||
- do not put API keys in query strings.
|
- validate anti-forgery tokens for login and logout posts.
|
||||||
|
|
||||||
Simplest implementation path:
|
The implementation path is:
|
||||||
|
|
||||||
1. Add `/dashboard/login`.
|
1. Add `/dashboard/login`.
|
||||||
2. User submits API key over HTTPS.
|
2. User submits API key over HTTPS.
|
||||||
@@ -281,6 +280,13 @@ Simplest implementation path:
|
|||||||
For local development, allow an explicit `Dashboard:AllowAnonymousLocalhost`
|
For local development, allow an explicit `Dashboard:AllowAnonymousLocalhost`
|
||||||
option. It must default to false.
|
option. It must default to false.
|
||||||
|
|
||||||
|
`DashboardAuthenticator` keeps API-key validation outside UI components. It
|
||||||
|
formats the submitted key as a bearer authorization header for
|
||||||
|
`IApiKeyVerifier`, rejects non-admin keys when `Dashboard:RequireAdminScope` is
|
||||||
|
enabled, and creates the dashboard cookie principal without storing raw API key
|
||||||
|
material. `DashboardAuthorizationHandler` enforces the cookie, admin-scope, and
|
||||||
|
explicit loopback bypass decisions for all protected dashboard routes.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Suggested configuration:
|
Suggested configuration:
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ MxGateway.Server
|
|||||||
Configuration
|
Configuration
|
||||||
Grpc
|
Grpc
|
||||||
MxAccessGatewayService
|
MxAccessGatewayService
|
||||||
RequestReplyMapper
|
MxAccessGrpcRequestValidator
|
||||||
EventMapper
|
MxAccessGrpcMapper
|
||||||
Dashboard
|
Dashboard
|
||||||
Pages
|
Pages
|
||||||
Components
|
Components
|
||||||
@@ -105,6 +105,15 @@ service MxAccessGateway {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`MxAccessGatewayService` implements these public RPCs in the gateway process.
|
||||||
|
It validates public requests with `MxAccessGrpcRequestValidator`, delegates
|
||||||
|
session lifecycle and command routing to `ISessionManager`, and maps worker
|
||||||
|
command replies and events through `MxAccessGrpcMapper`. Session lookup,
|
||||||
|
validation, and worker transport failures become gRPC status errors. MXAccess
|
||||||
|
method replies that reached the worker remain `MxCommandReply` payloads so
|
||||||
|
HRESULT values, status arrays, and method-specific reply fields survive
|
||||||
|
transport boundaries.
|
||||||
|
|
||||||
Add this later only after the command and event model is stable:
|
Add this later only after the command and event model is stable:
|
||||||
|
|
||||||
```protobuf
|
```protobuf
|
||||||
@@ -197,13 +206,23 @@ accounting and a clear fan-out policy.
|
|||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
1. Validate session id and authorize event access.
|
1. Validate session id and authorize event access.
|
||||||
2. Attach a stream cursor to the session event channel.
|
2. Attach the single active subscriber lease for the session.
|
||||||
3. Send events in worker sequence order.
|
3. Read worker events into a bounded public stream queue.
|
||||||
4. Stop on client cancellation, session close, or session fault.
|
4. Send events in worker sequence order.
|
||||||
5. Emit a terminal status when the session faults if gRPC status alone cannot
|
5. Stop on client cancellation, session close, or session fault.
|
||||||
|
6. Emit a terminal status when the session faults if gRPC status alone cannot
|
||||||
preserve the required details.
|
preserve the required details.
|
||||||
|
|
||||||
The gateway must not reorder events from one worker.
|
`EventStreamService` owns subscriber tracking and public stream backpressure.
|
||||||
|
The default policy allows one active subscriber per session. A second subscriber
|
||||||
|
is rejected with `EventSubscriberAlreadyActive`. Stream cancellation releases
|
||||||
|
the subscriber lease so a later stream can attach to the session.
|
||||||
|
|
||||||
|
The gateway must not reorder events from one worker. `EventStreamService` writes
|
||||||
|
mapped events to a bounded first-in, first-out queue and faults the session with
|
||||||
|
`EventQueueOverflow` if the queue fills. The gateway does not synthesize
|
||||||
|
`OperationComplete`; it forwards that family only when the worker reports a
|
||||||
|
native MXAccess `OperationComplete` event.
|
||||||
|
|
||||||
## Web Dashboard
|
## Web Dashboard
|
||||||
|
|
||||||
@@ -330,6 +349,20 @@ The worker remains authoritative for MXAccess handles. The gateway may keep a
|
|||||||
shadow state for diagnostics, but it must not invent, rewrite, or recycle
|
shadow state for diagnostics, but it must not invent, rewrite, or recycle
|
||||||
MXAccess handles.
|
MXAccess handles.
|
||||||
|
|
||||||
|
`SessionManager` owns the current in-memory session registry. It allocates a
|
||||||
|
session id, creates the worker pipe name and nonce, registers the session before
|
||||||
|
worker startup, and removes the session if startup fails. A successful
|
||||||
|
`OpenSession` attaches the ready `IWorkerClient` and transitions the session to
|
||||||
|
`Ready`.
|
||||||
|
|
||||||
|
Only `Ready` sessions accept command and event operations. `CloseSession` is
|
||||||
|
idempotent for sessions still known to the registry: the first close shuts down
|
||||||
|
the worker, and later closes return the final `Closed` state. Lease handling is
|
||||||
|
exposed as a session hook so a monitor can close expired sessions without
|
||||||
|
embedding lease policy in the worker client. Gateway shutdown walks the
|
||||||
|
registry, closes each known session, and kills a worker if graceful shutdown
|
||||||
|
fails.
|
||||||
|
|
||||||
## Worker Launch
|
## Worker Launch
|
||||||
|
|
||||||
The gateway should launch the worker using explicit configuration:
|
The gateway should launch the worker using explicit configuration:
|
||||||
@@ -561,7 +594,8 @@ worker MXAccess event
|
|||||||
-> worker outbound event queue
|
-> worker outbound event queue
|
||||||
-> worker pipe writer
|
-> worker pipe writer
|
||||||
-> gateway read loop
|
-> gateway read loop
|
||||||
-> session event channel
|
-> worker client event queue
|
||||||
|
-> EventStreamService bounded stream queue
|
||||||
-> gRPC StreamEvents
|
-> gRPC StreamEvents
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -575,13 +609,15 @@ The gateway should record:
|
|||||||
|
|
||||||
Default backpressure policy for parity testing should be fail-fast:
|
Default backpressure policy for parity testing should be fail-fast:
|
||||||
|
|
||||||
1. If the session event channel fills, fault the session.
|
1. If the worker client event queue fills, fault the worker client.
|
||||||
|
2. If the public stream queue fills, fault the gateway session.
|
||||||
2. Preserve the overflow details in logs and metrics.
|
2. Preserve the overflow details in logs and metrics.
|
||||||
3. Do not silently drop data-change events.
|
3. Do not silently drop data-change events.
|
||||||
|
|
||||||
Do not set a production event-rate target before measurement. Emit event rate,
|
Do not set a production event-rate target before measurement. `GatewayMetrics`
|
||||||
queue depth, stream send latency, and overflow metrics. Later production modes
|
records received event counts by family, queue depth, stream disconnects, and
|
||||||
may support explicit coalescing by item handle as an opt-in behavior.
|
overflow counts. Later production modes may support explicit coalescing by item
|
||||||
|
handle as an opt-in behavior.
|
||||||
|
|
||||||
The gateway should not synthesize `OperationComplete` from write completion,
|
The gateway should not synthesize `OperationComplete` from write completion,
|
||||||
command replies, ASB completion queues, or completion-only status frames. Forward
|
command replies, ASB completion queues, or completion-only status frames. Forward
|
||||||
@@ -627,6 +663,16 @@ server-streaming calls and stores the authenticated `ApiKeyIdentity` in
|
|||||||
`Authentication:Mode` set to `Disabled` bypasses API-key verification for local
|
`Authentication:Mode` set to `Disabled` bypasses API-key verification for local
|
||||||
development only.
|
development only.
|
||||||
|
|
||||||
|
Dashboard authentication reuses the API-key verifier and scope model. The
|
||||||
|
dashboard login endpoint accepts the key in a form post, checks `admin` scope
|
||||||
|
when `Dashboard:RequireAdminScope` is enabled, and signs in with the
|
||||||
|
`MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only, secure, strict
|
||||||
|
SameSite, and scoped with the `__Host-MxGatewayDashboard` name. Logout clears
|
||||||
|
that cookie. Login and logout posts use anti-forgery validation, and dashboard
|
||||||
|
API keys are not accepted in query strings. `Dashboard:AllowAnonymousLocalhost`
|
||||||
|
allows only loopback requests to bypass the dashboard cookie requirement and
|
||||||
|
defaults to `false`.
|
||||||
|
|
||||||
Recommended scopes:
|
Recommended scopes:
|
||||||
|
|
||||||
- `session:open`
|
- `session:open`
|
||||||
|
|||||||
@@ -189,6 +189,8 @@ Tests:
|
|||||||
|
|
||||||
Labels: `area:worker`, `type:feature`, `priority:p0`
|
Labels: `area:worker`, `type:feature`, `priority:p0`
|
||||||
|
|
||||||
|
Status: implemented.
|
||||||
|
|
||||||
Deliverables:
|
Deliverables:
|
||||||
|
|
||||||
- `Register`,
|
- `Register`,
|
||||||
@@ -447,4 +449,3 @@ Acceptance criteria:
|
|||||||
|
|
||||||
- each public method has planned parity fixture or documented gap,
|
- each public method has planned parity fixture or documented gap,
|
||||||
- gateway results preserve HRESULT/status/value/event shape.
|
- gateway results preserve HRESULT/status/value/event shape.
|
||||||
|
|
||||||
|
|||||||
@@ -289,6 +289,16 @@ The worker should reference the interop assembly and instantiate
|
|||||||
`LMXProxyServerClass` on the dedicated STA thread. Keep the ProgID and assembly
|
`LMXProxyServerClass` on the dedicated STA thread. Keep the ProgID and assembly
|
||||||
path configurable for diagnostics, but this COM class is the v1 default.
|
path configurable for diagnostics, but this COM class is the v1 default.
|
||||||
|
|
||||||
|
`MxAccessStaSession` owns the initial COM creation path. It starts `StaRuntime`,
|
||||||
|
creates `LMXProxyServerClass` through `MxAccessComObjectFactory` on the STA,
|
||||||
|
attaches `MxAccessBaseEventSink`, and returns `WorkerReady` only after those
|
||||||
|
steps succeed. `MxAccessSession` keeps the raw COM object private, records the
|
||||||
|
STA managed thread id that created it, detaches the base event sink during
|
||||||
|
disposal, and releases the COM reference on the STA. After creation,
|
||||||
|
`MxAccessStaSession` owns a `StaCommandDispatcher` backed by
|
||||||
|
`MxAccessCommandExecutor`; `DispatchAsync` queues contract commands back to the
|
||||||
|
same STA instead of exposing the COM object to callers.
|
||||||
|
|
||||||
Creation rules:
|
Creation rules:
|
||||||
|
|
||||||
- Create COM object only on the STA.
|
- Create COM object only on the STA.
|
||||||
@@ -306,6 +316,11 @@ If COM creation fails, the worker should send a structured fault with:
|
|||||||
- worker process id,
|
- worker process id,
|
||||||
- session id.
|
- session id.
|
||||||
|
|
||||||
|
`WorkerPipeSession` maps startup exceptions from this path to
|
||||||
|
`WorkerFaultCategory.MxaccessCreationFailed`, includes the captured HRESULT
|
||||||
|
when the exception exposes one, and does not send `WorkerReady` after a failed
|
||||||
|
COM creation attempt.
|
||||||
|
|
||||||
## Event Sink
|
## Event Sink
|
||||||
|
|
||||||
The worker must subscribe to every public MXAccess event family:
|
The worker must subscribe to every public MXAccess event family:
|
||||||
@@ -402,6 +417,21 @@ Diagnostics:
|
|||||||
Implement method-specific dispatch instead of a generic string method invoker.
|
Implement method-specific dispatch instead of a generic string method invoker.
|
||||||
Parity tests need stable command-specific request and reply shapes.
|
Parity tests need stable command-specific request and reply shapes.
|
||||||
|
|
||||||
|
`MxAccessCommandExecutor` implements the first command pair:
|
||||||
|
|
||||||
|
- `Register` calls `LMXProxyServerClass.Register` with the requested client
|
||||||
|
name and preserves the returned server handle in both `ReturnValue` and
|
||||||
|
`RegisterReply.ServerHandle`.
|
||||||
|
- `Unregister` calls `LMXProxyServerClass.Unregister` with the requested server
|
||||||
|
handle. The reply has no method-specific payload because the public MXAccess
|
||||||
|
method returns `void`.
|
||||||
|
|
||||||
|
Both commands set `Hresult` to `0` only after the COM call returns normally.
|
||||||
|
COM exceptions flow through `StaCommandDispatcher`, which captures the thrown
|
||||||
|
HRESULT and converts the reply to `ProtocolStatusCode.MxaccessFailure`.
|
||||||
|
`MxAccessStaSession.GetRegisteredServerHandlesAsync` returns an STA-read
|
||||||
|
snapshot of tracked server handles for diagnostics and future cleanup logic.
|
||||||
|
|
||||||
## Handle Registry
|
## Handle Registry
|
||||||
|
|
||||||
The worker should track MXAccess state for diagnostics and cleanup, while still
|
The worker should track MXAccess state for diagnostics and cleanup, while still
|
||||||
@@ -422,6 +452,8 @@ Rules:
|
|||||||
|
|
||||||
- Do not invent handles.
|
- Do not invent handles.
|
||||||
- Do not rewrite handles returned by MXAccess.
|
- Do not rewrite handles returned by MXAccess.
|
||||||
|
- Record server handles only after `Register` succeeds.
|
||||||
|
- Remove server handles only after `Unregister` succeeds.
|
||||||
- Preserve invalid-handle behavior from MXAccess.
|
- Preserve invalid-handle behavior from MXAccess.
|
||||||
- Preserve cross-server handle behavior from MXAccess.
|
- Preserve cross-server handle behavior from MXAccess.
|
||||||
- Use registry state for cleanup and diagnostics, not semantic correction.
|
- Use registry state for cleanup and diagnostics, not semantic correction.
|
||||||
|
|||||||
+34
-8
@@ -107,6 +107,15 @@ worker, correlation, command, and client identity fields with redaction applied
|
|||||||
before values enter log state. `GatewayMetrics` exposes counters, gauges, and
|
before values enter log state. `GatewayMetrics` exposes counters, gauges, and
|
||||||
histograms through .NET `Meter` and a snapshot API that dashboard services can
|
histograms through .NET `Meter` and a snapshot API that dashboard services can
|
||||||
project without binding to a metrics exporter.
|
project without binding to a metrics exporter.
|
||||||
|
`DashboardSnapshotService` projects sessions, workers, metrics, faults, and
|
||||||
|
effective configuration into immutable DTOs for read-only dashboard rendering.
|
||||||
|
Dashboard routes use the same API-key verifier as gRPC. `/dashboard/login`
|
||||||
|
accepts the API key in a form body, validates the configured `admin` scope,
|
||||||
|
and issues an HTTP-only secure cookie for subsequent dashboard requests.
|
||||||
|
`/dashboard/logout` clears that cookie. Login and logout posts validate
|
||||||
|
anti-forgery tokens, and API keys are never accepted through query strings.
|
||||||
|
`Dashboard:AllowAnonymousLocalhost` can bypass the cookie requirement for
|
||||||
|
loopback requests only when explicitly enabled.
|
||||||
|
|
||||||
### Worker Process
|
### Worker Process
|
||||||
|
|
||||||
@@ -518,11 +527,7 @@ Worker policy:
|
|||||||
|
|
||||||
- bounded outbound event channel,
|
- bounded outbound event channel,
|
||||||
- never block MXAccess event handler on pipe writes,
|
- never block MXAccess event handler on pipe writes,
|
||||||
- if the outbound channel is full, apply configured policy:
|
- fail the worker session when the outbound channel is full.
|
||||||
- disconnect session,
|
|
||||||
- drop oldest low-priority data-change events,
|
|
||||||
- coalesce data changes by item handle,
|
|
||||||
- or block briefly then fault.
|
|
||||||
|
|
||||||
For full parity testing, default should be fail-fast rather than silent drop.
|
For full parity testing, default should be fail-fast rather than silent drop.
|
||||||
For production high-rate telemetry, add explicit coalescing modes.
|
For production high-rate telemetry, add explicit coalescing modes.
|
||||||
@@ -531,9 +536,15 @@ Gateway policy:
|
|||||||
|
|
||||||
- one event sequencer per session,
|
- one event sequencer per session,
|
||||||
- preserve per-session event order,
|
- preserve per-session event order,
|
||||||
- support multiple client event subscribers only if explicitly required,
|
- allow one active client event subscriber per session,
|
||||||
- apply backpressure from slow gRPC streams,
|
- reject a second subscriber with a clear session error,
|
||||||
- disconnect or coalesce according to client-selected mode.
|
- use a bounded `EventStreamService` queue between worker events and gRPC
|
||||||
|
writes,
|
||||||
|
- fault the session when the bounded stream queue overflows,
|
||||||
|
- detach the subscriber when the stream is canceled.
|
||||||
|
|
||||||
|
The gateway forwards only events reported by the worker. It does not synthesize
|
||||||
|
`OperationComplete` from write completion, command replies, or status frames.
|
||||||
|
|
||||||
## Isolation And Fault Handling
|
## Isolation And Fault Handling
|
||||||
|
|
||||||
@@ -799,6 +810,12 @@ Core operations:
|
|||||||
- track worker state,
|
- track worker state,
|
||||||
- close or kill worker.
|
- close or kill worker.
|
||||||
|
|
||||||
|
The gateway implementation keeps sessions in an in-memory `SessionRegistry`
|
||||||
|
keyed by session id. `SessionManager` owns the state machine, creates
|
||||||
|
per-session pipe names and nonces, starts the worker through the worker-client
|
||||||
|
factory, gates commands to `Ready` sessions, exposes lease-close hooks, and
|
||||||
|
cleans up workers during gateway shutdown.
|
||||||
|
|
||||||
State machine:
|
State machine:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -846,6 +863,15 @@ The gRPC layer should be thin:
|
|||||||
Avoid embedding MXAccess-specific business logic in gRPC handlers. Keep the
|
Avoid embedding MXAccess-specific business logic in gRPC handlers. Keep the
|
||||||
translation code testable.
|
translation code testable.
|
||||||
|
|
||||||
|
The gateway maps `MxAccessGateway` to `MxAccessGatewayService`. The service
|
||||||
|
implements `OpenSession`, `CloseSession`, `Invoke`, and `StreamEvents` by
|
||||||
|
validating public requests, delegating session work to `ISessionManager`, and
|
||||||
|
using explicit mapper code for public-to-worker commands and worker replies.
|
||||||
|
`StreamEvents` delegates subscriber ownership, ordering, and backpressure to
|
||||||
|
`EventStreamService`. Missing sessions and transport failures return gRPC
|
||||||
|
status errors; worker command replies preserve MXAccess HRESULT and status
|
||||||
|
details in the public reply.
|
||||||
|
|
||||||
## C# Worker Versus C++ Worker
|
## C# Worker Versus C++ Worker
|
||||||
|
|
||||||
Start with a C# .NET Framework 4.8 x86 worker.
|
Start with a C# .NET Framework 4.8 x86 worker.
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public static class DashboardAuthenticationDefaults
|
||||||
|
{
|
||||||
|
public const string AuthenticationScheme = "MxGateway.Dashboard";
|
||||||
|
public const string AuthorizationPolicy = "MxGateway.Dashboard";
|
||||||
|
public const string ScopeClaimType = "scope";
|
||||||
|
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
|
||||||
|
public const string CookieName = "__Host-MxGatewayDashboard";
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed record DashboardAuthenticationResult(
|
||||||
|
bool Succeeded,
|
||||||
|
ClaimsPrincipal? Principal,
|
||||||
|
string? FailureMessage)
|
||||||
|
{
|
||||||
|
public static DashboardAuthenticationResult Success(ClaimsPrincipal principal)
|
||||||
|
{
|
||||||
|
return new DashboardAuthenticationResult(true, principal, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DashboardAuthenticationResult Fail(string failureMessage)
|
||||||
|
{
|
||||||
|
return new DashboardAuthenticationResult(false, null, failureMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Security.Authentication;
|
||||||
|
using MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed class DashboardAuthenticator(
|
||||||
|
IApiKeyVerifier apiKeyVerifier,
|
||||||
|
IOptions<GatewayOptions> options) : IDashboardAuthenticator
|
||||||
|
{
|
||||||
|
private const string GenericFailureMessage = "The API key is invalid or is not authorized for dashboard access.";
|
||||||
|
|
||||||
|
public async Task<DashboardAuthenticationResult> AuthenticateAsync(
|
||||||
|
string? apiKey,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (options.Value.Authentication.Mode == AuthenticationMode.Disabled)
|
||||||
|
{
|
||||||
|
return DashboardAuthenticationResult.Success(CreatePrincipal(new ApiKeyIdentity(
|
||||||
|
KeyId: "authentication-disabled",
|
||||||
|
KeyPrefix: "authentication-disabled",
|
||||||
|
DisplayName: "Authentication Disabled",
|
||||||
|
Scopes: new HashSet<string>([GatewayScopes.Admin], StringComparer.Ordinal))));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiKeyVerificationResult verificationResult = await apiKeyVerifier
|
||||||
|
.VerifyAsync(FormatAuthorizationHeader(apiKey), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!verificationResult.Succeeded || verificationResult.Identity is null)
|
||||||
|
{
|
||||||
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.Value.Dashboard.RequireAdminScope
|
||||||
|
&& !verificationResult.Identity.Scopes.Contains(GatewayScopes.Admin))
|
||||||
|
{
|
||||||
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DashboardAuthenticationResult.Success(CreatePrincipal(verificationResult.Identity));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatAuthorizationHeader(string apiKey)
|
||||||
|
{
|
||||||
|
string trimmedApiKey = apiKey.Trim();
|
||||||
|
|
||||||
|
return trimmedApiKey.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? trimmedApiKey
|
||||||
|
: $"Bearer {trimmedApiKey}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal CreatePrincipal(ApiKeyIdentity identity)
|
||||||
|
{
|
||||||
|
List<Claim> claims =
|
||||||
|
[
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, identity.KeyId),
|
||||||
|
new Claim(ClaimTypes.Name, identity.DisplayName),
|
||||||
|
new Claim(DashboardAuthenticationDefaults.KeyPrefixClaimType, identity.KeyPrefix)
|
||||||
|
];
|
||||||
|
|
||||||
|
claims.AddRange(identity.Scopes.Select(scope => new Claim(
|
||||||
|
DashboardAuthenticationDefaults.ScopeClaimType,
|
||||||
|
scope)));
|
||||||
|
|
||||||
|
ClaimsIdentity claimsIdentity = new(
|
||||||
|
claims,
|
||||||
|
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||||
|
ClaimTypes.Name,
|
||||||
|
DashboardAuthenticationDefaults.ScopeClaimType);
|
||||||
|
|
||||||
|
return new ClaimsPrincipal(claimsIdentity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Net;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed class DashboardAuthorizationHandler(
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
IOptions<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement>
|
||||||
|
{
|
||||||
|
protected override Task HandleRequirementAsync(
|
||||||
|
AuthorizationHandlerContext context,
|
||||||
|
DashboardAuthorizationRequirement requirement)
|
||||||
|
{
|
||||||
|
GatewayOptions gatewayOptions = options.Value;
|
||||||
|
|
||||||
|
if (gatewayOptions.Authentication.Mode == AuthenticationMode.Disabled)
|
||||||
|
{
|
||||||
|
context.Succeed(requirement);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gatewayOptions.Dashboard.AllowAnonymousLocalhost && IsLoopbackRequest())
|
||||||
|
{
|
||||||
|
context.Succeed(requirement);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.User.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gatewayOptions.Dashboard.RequireAdminScope || HasAdminScope(context))
|
||||||
|
{
|
||||||
|
context.Succeed(requirement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsLoopbackRequest()
|
||||||
|
{
|
||||||
|
IPAddress? remoteAddress = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress;
|
||||||
|
|
||||||
|
return remoteAddress is not null && IPAddress.IsLoopback(remoteAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasAdminScope(AuthorizationHandlerContext context)
|
||||||
|
{
|
||||||
|
return context.User.HasClaim(
|
||||||
|
DashboardAuthenticationDefaults.ScopeClaimType,
|
||||||
|
GatewayScopes.Admin);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed class DashboardAuthorizationRequirement : IAuthorizationRequirement;
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using Microsoft.AspNetCore.Antiforgery;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public static class DashboardEndpointRouteBuilderExtensions
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapGatewayDashboard(this IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
IConfiguration configuration = endpoints.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||||
|
IConfigurationSection dashboardSection = configuration
|
||||||
|
.GetSection($"{GatewayOptions.SectionName}:Dashboard");
|
||||||
|
|
||||||
|
if (bool.TryParse(dashboardSection["Enabled"], out bool enabled) && !enabled)
|
||||||
|
{
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
string pathBase = NormalizePathBase(dashboardSection["PathBase"] ?? new DashboardOptions().PathBase);
|
||||||
|
RouteGroupBuilder dashboard = endpoints.MapGroup(pathBase);
|
||||||
|
|
||||||
|
dashboard.MapGet(
|
||||||
|
"/",
|
||||||
|
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardSnapshotService snapshotService) =>
|
||||||
|
GetDashboardHomeAsync(httpContext, antiforgery, snapshotService, pathBase))
|
||||||
|
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy)
|
||||||
|
.WithName("DashboardHome");
|
||||||
|
|
||||||
|
dashboard.MapGet(
|
||||||
|
"/login",
|
||||||
|
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery, pathBase))
|
||||||
|
.AllowAnonymous()
|
||||||
|
.WithName("DashboardLogin");
|
||||||
|
|
||||||
|
dashboard.MapPost(
|
||||||
|
"/login",
|
||||||
|
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
||||||
|
PostLoginAsync(httpContext, antiforgery, authenticator, pathBase))
|
||||||
|
.AllowAnonymous()
|
||||||
|
.WithName("DashboardLoginPost");
|
||||||
|
|
||||||
|
dashboard.MapPost(
|
||||||
|
"/logout",
|
||||||
|
(HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery, pathBase))
|
||||||
|
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy)
|
||||||
|
.WithName("DashboardLogout");
|
||||||
|
|
||||||
|
dashboard.MapGet("/denied", () => Results.Content(
|
||||||
|
RenderPage("Access denied", "<p>The signed-in API key is not authorized for dashboard access.</p>"),
|
||||||
|
"text/html"))
|
||||||
|
.AllowAnonymous()
|
||||||
|
.WithName("DashboardAccessDenied");
|
||||||
|
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ContentHttpResult GetDashboardHomeAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
IAntiforgery antiforgery,
|
||||||
|
IDashboardSnapshotService snapshotService,
|
||||||
|
string pathBase)
|
||||||
|
{
|
||||||
|
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext);
|
||||||
|
DashboardSnapshot snapshot = snapshotService.GetSnapshot();
|
||||||
|
string requestToken = tokens.RequestToken ?? string.Empty;
|
||||||
|
string body = $"""
|
||||||
|
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/logout")}" class="mb-3">
|
||||||
|
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
|
||||||
|
<button type="submit">Sign out</button>
|
||||||
|
</form>
|
||||||
|
<dl>
|
||||||
|
<dt>Open sessions</dt>
|
||||||
|
<dd>{snapshot.Sessions.Count}</dd>
|
||||||
|
<dt>Workers</dt>
|
||||||
|
<dd>{snapshot.Workers.Count}</dd>
|
||||||
|
<dt>Faults</dt>
|
||||||
|
<dd>{snapshot.Faults.Count}</dd>
|
||||||
|
</dl>
|
||||||
|
""";
|
||||||
|
|
||||||
|
return TypedResults.Content(RenderPage("MXAccess Gateway Dashboard", body), "text/html");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<ContentHttpResult> GetLoginAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
IAntiforgery antiforgery,
|
||||||
|
string pathBase)
|
||||||
|
{
|
||||||
|
string returnUrl = SanitizeReturnUrl(
|
||||||
|
httpContext.Request.Query["returnUrl"].ToString(),
|
||||||
|
pathBase);
|
||||||
|
|
||||||
|
return Task.FromResult(TypedResults.Content(
|
||||||
|
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, failureMessage: null),
|
||||||
|
"text/html"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> PostLoginAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
IAntiforgery antiforgery,
|
||||||
|
IDashboardAuthenticator authenticator,
|
||||||
|
string pathBase)
|
||||||
|
{
|
||||||
|
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
|
||||||
|
|
||||||
|
IFormCollection form = await httpContext.Request
|
||||||
|
.ReadFormAsync(httpContext.RequestAborted)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
string returnUrl = SanitizeReturnUrl(
|
||||||
|
form["returnUrl"].ToString(),
|
||||||
|
pathBase);
|
||||||
|
|
||||||
|
DashboardAuthenticationResult result = await authenticator
|
||||||
|
.AuthenticateAsync(form["apiKey"].ToString(), httpContext.RequestAborted)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!result.Succeeded || result.Principal is null)
|
||||||
|
{
|
||||||
|
return TypedResults.Content(
|
||||||
|
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, result.FailureMessage),
|
||||||
|
"text/html",
|
||||||
|
statusCode: StatusCodes.Status401Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
await httpContext
|
||||||
|
.SignInAsync(DashboardAuthenticationDefaults.AuthenticationScheme, result.Principal)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Results.LocalRedirect(returnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> PostLogoutAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
IAntiforgery antiforgery,
|
||||||
|
string pathBase)
|
||||||
|
{
|
||||||
|
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
|
||||||
|
await httpContext
|
||||||
|
.SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Results.LocalRedirect($"{pathBase}/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RenderLoginPage(
|
||||||
|
HttpContext httpContext,
|
||||||
|
IAntiforgery antiforgery,
|
||||||
|
string returnUrl,
|
||||||
|
string pathBase,
|
||||||
|
string? failureMessage)
|
||||||
|
{
|
||||||
|
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext);
|
||||||
|
string requestToken = tokens.RequestToken ?? string.Empty;
|
||||||
|
string alert = string.IsNullOrWhiteSpace(failureMessage)
|
||||||
|
? string.Empty
|
||||||
|
: $"<p role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
|
||||||
|
|
||||||
|
string body = $"""
|
||||||
|
{alert}
|
||||||
|
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/login")}">
|
||||||
|
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
|
||||||
|
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
|
||||||
|
<label for="apiKey">API key</label>
|
||||||
|
<input id="apiKey" name="apiKey" type="password" autocomplete="off" />
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
""";
|
||||||
|
|
||||||
|
return RenderPage("Dashboard Sign In", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RenderPage(string title, string body)
|
||||||
|
{
|
||||||
|
return $"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{HtmlEncoder.Default.Encode(title)}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>{HtmlEncoder.Default.Encode(title)}</h1>
|
||||||
|
{body}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizePathBase(string pathBase)
|
||||||
|
{
|
||||||
|
string normalized = pathBase.TrimEnd('/');
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(normalized) || !normalized.StartsWith("/", StringComparison.Ordinal)
|
||||||
|
? "/dashboard"
|
||||||
|
: normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeReturnUrl(string? returnUrl, string pathBase)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(returnUrl)
|
||||||
|
|| !returnUrl.StartsWith("/", StringComparison.Ordinal)
|
||||||
|
|| returnUrl.StartsWith("//", StringComparison.Ordinal)
|
||||||
|
|| !returnUrl.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| Uri.TryCreate(returnUrl, UriKind.Absolute, out _))
|
||||||
|
{
|
||||||
|
return pathBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed record DashboardFaultSummary(
|
||||||
|
string Source,
|
||||||
|
string? SessionId,
|
||||||
|
int? WorkerProcessId,
|
||||||
|
string State,
|
||||||
|
string Message,
|
||||||
|
DateTimeOffset ObservedAt);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed record DashboardMetricSummary(
|
||||||
|
string Name,
|
||||||
|
long Value,
|
||||||
|
string? Dimension = null);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using MxGateway.Server.Diagnostics;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
internal static class DashboardRedactor
|
||||||
|
{
|
||||||
|
private static readonly string[] SensitiveTextMarkers =
|
||||||
|
[
|
||||||
|
"apikey",
|
||||||
|
"api_key",
|
||||||
|
"authorization",
|
||||||
|
"credential",
|
||||||
|
"password",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
];
|
||||||
|
|
||||||
|
public static string? Redact(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.Contains("mxgw_", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return GatewayLogRedactor.RedactClientIdentity(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SensitiveTextMarkers.Any(marker => value.Contains(marker, StringComparison.OrdinalIgnoreCase))
|
||||||
|
? GatewayLogRedactor.RedactedValue
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public static class DashboardServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
||||||
|
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
||||||
|
services.AddHttpContextAccessor();
|
||||||
|
services.AddAntiforgery();
|
||||||
|
services
|
||||||
|
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.Configure<IOptions<GatewayOptions>>(ConfigureCookieOptions);
|
||||||
|
services.AddAuthorization(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy(
|
||||||
|
DashboardAuthenticationDefaults.AuthorizationPolicy,
|
||||||
|
policy => policy.AddRequirements(new DashboardAuthorizationRequirement()));
|
||||||
|
});
|
||||||
|
services.AddSingleton<IAuthorizationHandler, DashboardAuthorizationHandler>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureCookieOptions(
|
||||||
|
CookieAuthenticationOptions cookieOptions,
|
||||||
|
IOptions<GatewayOptions> gatewayOptions)
|
||||||
|
{
|
||||||
|
string pathBase = gatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
|
||||||
|
if (string.IsNullOrWhiteSpace(pathBase))
|
||||||
|
{
|
||||||
|
pathBase = "/dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
|
||||||
|
cookieOptions.Cookie.HttpOnly = true;
|
||||||
|
cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||||
|
cookieOptions.Cookie.SameSite = SameSiteMode.Strict;
|
||||||
|
cookieOptions.Cookie.Path = "/";
|
||||||
|
cookieOptions.LoginPath = $"{pathBase}/login";
|
||||||
|
cookieOptions.LogoutPath = $"{pathBase}/logout";
|
||||||
|
cookieOptions.AccessDeniedPath = $"{pathBase}/denied";
|
||||||
|
cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||||
|
cookieOptions.SlidingExpiration = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed record DashboardSessionSummary(
|
||||||
|
string SessionId,
|
||||||
|
string BackendName,
|
||||||
|
SessionState State,
|
||||||
|
string? ClientIdentity,
|
||||||
|
string? ClientSessionName,
|
||||||
|
string? ClientCorrelationId,
|
||||||
|
DateTimeOffset OpenedAt,
|
||||||
|
DateTimeOffset LastClientActivityAt,
|
||||||
|
DateTimeOffset? LeaseExpiresAt,
|
||||||
|
int? WorkerProcessId,
|
||||||
|
WorkerClientState? WorkerState,
|
||||||
|
DateTimeOffset? LastWorkerHeartbeatAt,
|
||||||
|
string? LastFault);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed record DashboardSnapshot(
|
||||||
|
DateTimeOffset GeneratedAt,
|
||||||
|
DateTimeOffset GatewayStartedAt,
|
||||||
|
TimeSpan GatewayUptime,
|
||||||
|
string GatewayStatus,
|
||||||
|
string GatewayVersion,
|
||||||
|
IReadOnlyList<DashboardSessionSummary> Sessions,
|
||||||
|
IReadOnlyList<DashboardWorkerSummary> Workers,
|
||||||
|
IReadOnlyList<DashboardMetricSummary> Metrics,
|
||||||
|
IReadOnlyList<DashboardFaultSummary> Faults,
|
||||||
|
EffectiveGatewayConfiguration Configuration);
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Metrics;
|
||||||
|
using MxGateway.Server.Sessions;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||||
|
{
|
||||||
|
private const string HealthyStatus = "Healthy";
|
||||||
|
|
||||||
|
private readonly ISessionRegistry _sessionRegistry;
|
||||||
|
private readonly GatewayMetrics _metrics;
|
||||||
|
private readonly IGatewayConfigurationProvider _configurationProvider;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly DateTimeOffset _gatewayStartedAt;
|
||||||
|
private readonly TimeSpan _snapshotInterval;
|
||||||
|
private readonly int _recentFaultLimit;
|
||||||
|
private readonly int _recentSessionLimit;
|
||||||
|
|
||||||
|
public DashboardSnapshotService(
|
||||||
|
ISessionRegistry sessionRegistry,
|
||||||
|
GatewayMetrics metrics,
|
||||||
|
IGatewayConfigurationProvider configurationProvider,
|
||||||
|
IOptions<GatewayOptions> options,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_sessionRegistry = sessionRegistry ?? throw new ArgumentNullException(nameof(sessionRegistry));
|
||||||
|
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||||
|
_configurationProvider = configurationProvider ?? throw new ArgumentNullException(nameof(configurationProvider));
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_gatewayStartedAt = _timeProvider.GetUtcNow();
|
||||||
|
_snapshotInterval = TimeSpan.FromMilliseconds(options.Value.Dashboard.SnapshotIntervalMilliseconds);
|
||||||
|
_recentFaultLimit = options.Value.Dashboard.RecentFaultLimit;
|
||||||
|
_recentSessionLimit = options.Value.Dashboard.RecentSessionLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DashboardSnapshot GetSnapshot()
|
||||||
|
{
|
||||||
|
DateTimeOffset generatedAt = _timeProvider.GetUtcNow();
|
||||||
|
IReadOnlyList<GatewaySession> sessions = _sessionRegistry.Snapshot()
|
||||||
|
.OrderByDescending(session => session.OpenedAt)
|
||||||
|
.ToArray();
|
||||||
|
IReadOnlyList<DashboardSessionSummary> sessionSummaries = sessions
|
||||||
|
.Take(ResolveLimit(_recentSessionLimit))
|
||||||
|
.Select(CreateSessionSummary)
|
||||||
|
.ToArray();
|
||||||
|
IReadOnlyList<DashboardWorkerSummary> workerSummaries = sessions
|
||||||
|
.Where(session => session.WorkerClient is not null)
|
||||||
|
.Select(CreateWorkerSummary)
|
||||||
|
.ToArray();
|
||||||
|
GatewayMetricsSnapshot metricsSnapshot = _metrics.GetSnapshot();
|
||||||
|
|
||||||
|
return new DashboardSnapshot(
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
GatewayStartedAt: _gatewayStartedAt,
|
||||||
|
GatewayUptime: generatedAt - _gatewayStartedAt,
|
||||||
|
GatewayStatus: HealthyStatus,
|
||||||
|
GatewayVersion: typeof(DashboardSnapshotService).Assembly.GetName().Version?.ToString() ?? "unknown",
|
||||||
|
Sessions: sessionSummaries,
|
||||||
|
Workers: workerSummaries,
|
||||||
|
Metrics: CreateMetricSummaries(metricsSnapshot),
|
||||||
|
Faults: CreateFaultSummaries(sessions, generatedAt),
|
||||||
|
Configuration: _configurationProvider.GetEffectiveConfiguration());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return GetSnapshot();
|
||||||
|
|
||||||
|
using PeriodicTimer timer = new(_snapshotInterval, _timeProvider);
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
bool hasNext;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
hasNext = await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasNext)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return GetSnapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DashboardSessionSummary CreateSessionSummary(GatewaySession session)
|
||||||
|
{
|
||||||
|
IWorkerClient? workerClient = session.WorkerClient;
|
||||||
|
|
||||||
|
return new DashboardSessionSummary(
|
||||||
|
SessionId: session.SessionId,
|
||||||
|
BackendName: session.BackendName,
|
||||||
|
State: session.State,
|
||||||
|
ClientIdentity: DashboardRedactor.Redact(session.ClientIdentity),
|
||||||
|
ClientSessionName: DashboardRedactor.Redact(session.ClientSessionName),
|
||||||
|
ClientCorrelationId: DashboardRedactor.Redact(session.ClientCorrelationId),
|
||||||
|
OpenedAt: session.OpenedAt,
|
||||||
|
LastClientActivityAt: session.LastClientActivityAt,
|
||||||
|
LeaseExpiresAt: session.LeaseExpiresAt,
|
||||||
|
WorkerProcessId: workerClient?.ProcessId,
|
||||||
|
WorkerState: workerClient?.State,
|
||||||
|
LastWorkerHeartbeatAt: workerClient?.LastHeartbeatAt,
|
||||||
|
LastFault: DashboardRedactor.Redact(session.FinalFault));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DashboardWorkerSummary CreateWorkerSummary(GatewaySession session)
|
||||||
|
{
|
||||||
|
IWorkerClient workerClient = session.WorkerClient!;
|
||||||
|
|
||||||
|
return new DashboardWorkerSummary(
|
||||||
|
SessionId: session.SessionId,
|
||||||
|
ProcessId: workerClient.ProcessId,
|
||||||
|
State: workerClient.State,
|
||||||
|
LastHeartbeatAt: workerClient.LastHeartbeatAt,
|
||||||
|
LastFault: DashboardRedactor.Redact(session.FinalFault));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<DashboardMetricSummary> CreateMetricSummaries(GatewayMetricsSnapshot snapshot)
|
||||||
|
{
|
||||||
|
List<DashboardMetricSummary> metrics =
|
||||||
|
[
|
||||||
|
new("mxgateway.sessions.open", snapshot.OpenSessions),
|
||||||
|
new("mxgateway.workers.running", snapshot.WorkersRunning),
|
||||||
|
new("mxgateway.events.queue.depth", snapshot.EventQueueDepth),
|
||||||
|
new("mxgateway.sessions.opened", snapshot.SessionsOpened),
|
||||||
|
new("mxgateway.sessions.closed", snapshot.SessionsClosed),
|
||||||
|
new("mxgateway.commands.started", snapshot.CommandsStarted),
|
||||||
|
new("mxgateway.commands.succeeded", snapshot.CommandsSucceeded),
|
||||||
|
new("mxgateway.commands.failed", snapshot.CommandsFailed),
|
||||||
|
new("mxgateway.events.received", snapshot.EventsReceived),
|
||||||
|
new("mxgateway.queues.overflows", snapshot.QueueOverflows),
|
||||||
|
new("mxgateway.faults", snapshot.Faults),
|
||||||
|
new("mxgateway.workers.killed", snapshot.WorkerKills),
|
||||||
|
new("mxgateway.workers.exited", snapshot.WorkerExits),
|
||||||
|
new("mxgateway.heartbeats.failed", snapshot.HeartbeatFailures),
|
||||||
|
new("mxgateway.grpc.streams.disconnected", snapshot.StreamDisconnects),
|
||||||
|
];
|
||||||
|
|
||||||
|
metrics.AddRange(snapshot.CommandFailuresByMethod
|
||||||
|
.OrderBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(entry => new DashboardMetricSummary("mxgateway.commands.failed", entry.Value, entry.Key)));
|
||||||
|
metrics.AddRange(snapshot.EventsByFamily
|
||||||
|
.OrderBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(entry => new DashboardMetricSummary("mxgateway.events.received", entry.Value, entry.Key)));
|
||||||
|
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<DashboardFaultSummary> CreateFaultSummaries(
|
||||||
|
IReadOnlyList<GatewaySession> sessions,
|
||||||
|
DateTimeOffset generatedAt)
|
||||||
|
{
|
||||||
|
return sessions
|
||||||
|
.Where(HasFault)
|
||||||
|
.Take(ResolveLimit(_recentFaultLimit))
|
||||||
|
.Select(session => new DashboardFaultSummary(
|
||||||
|
Source: session.WorkerClient?.State == WorkerClientState.Faulted ? "Worker" : "Session",
|
||||||
|
SessionId: session.SessionId,
|
||||||
|
WorkerProcessId: session.WorkerProcessId,
|
||||||
|
State: session.WorkerClient?.State == WorkerClientState.Faulted
|
||||||
|
? WorkerClientState.Faulted.ToString()
|
||||||
|
: session.State.ToString(),
|
||||||
|
Message: DashboardRedactor.Redact(session.FinalFault) ?? "Faulted",
|
||||||
|
ObservedAt: generatedAt))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasFault(GatewaySession session)
|
||||||
|
{
|
||||||
|
return session.State == MxGateway.Contracts.Proto.SessionState.Faulted
|
||||||
|
|| session.WorkerClient?.State == WorkerClientState.Faulted
|
||||||
|
|| !string.IsNullOrWhiteSpace(session.FinalFault);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveLimit(int configuredLimit)
|
||||||
|
{
|
||||||
|
return configuredLimit < 0 ? 0 : configuredLimit;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed record DashboardWorkerSummary(
|
||||||
|
string SessionId,
|
||||||
|
int? ProcessId,
|
||||||
|
WorkerClientState State,
|
||||||
|
DateTimeOffset LastHeartbeatAt,
|
||||||
|
string? LastFault);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public interface IDashboardAuthenticator
|
||||||
|
{
|
||||||
|
Task<DashboardAuthenticationResult> AuthenticateAsync(
|
||||||
|
string? apiKey,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public interface IDashboardSnapshotService
|
||||||
|
{
|
||||||
|
DashboardSnapshot GetSnapshot();
|
||||||
|
|
||||||
|
IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
using MxGateway.Contracts;
|
using MxGateway.Contracts;
|
||||||
using MxGateway.Server.Configuration;
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Dashboard;
|
||||||
using MxGateway.Server.Diagnostics;
|
using MxGateway.Server.Diagnostics;
|
||||||
|
using MxGateway.Server.Grpc;
|
||||||
using MxGateway.Server.Metrics;
|
using MxGateway.Server.Metrics;
|
||||||
using MxGateway.Server.Security.Authentication;
|
using MxGateway.Server.Security.Authentication;
|
||||||
using MxGateway.Server.Security.Authorization;
|
using MxGateway.Server.Security.Authorization;
|
||||||
|
using MxGateway.Server.Sessions;
|
||||||
using MxGateway.Server.Workers;
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
namespace MxGateway.Server;
|
namespace MxGateway.Server;
|
||||||
@@ -16,6 +19,8 @@ public static class GatewayApplication
|
|||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
app.UseGatewayRequestLoggingScope();
|
app.UseGatewayRequestLoggingScope();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
app.MapGatewayEndpoints();
|
app.MapGatewayEndpoints();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
@@ -30,7 +35,12 @@ public static class GatewayApplication
|
|||||||
builder.Services.AddGatewayGrpcAuthorization();
|
builder.Services.AddGatewayGrpcAuthorization();
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
builder.Services.AddSingleton<GatewayMetrics>();
|
builder.Services.AddSingleton<GatewayMetrics>();
|
||||||
|
builder.Services.AddSingleton<MxAccessGrpcMapper>();
|
||||||
|
builder.Services.AddSingleton<MxAccessGrpcRequestValidator>();
|
||||||
|
builder.Services.AddSingleton<IEventStreamService, EventStreamService>();
|
||||||
builder.Services.AddWorkerProcessLauncher();
|
builder.Services.AddWorkerProcessLauncher();
|
||||||
|
builder.Services.AddGatewaySessions();
|
||||||
|
builder.Services.AddGatewayDashboard();
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
@@ -47,6 +57,9 @@ public static class GatewayApplication
|
|||||||
WorkerProtocolVersion: GatewayContractInfo.WorkerProtocolVersion)))
|
WorkerProtocolVersion: GatewayContractInfo.WorkerProtocolVersion)))
|
||||||
.WithName("LiveHealth");
|
.WithName("LiveHealth");
|
||||||
|
|
||||||
|
endpoints.MapGrpcService<MxAccessGatewayService>();
|
||||||
|
endpoints.MapGatewayDashboard();
|
||||||
|
|
||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
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.Server.Grpc;
|
||||||
|
|
||||||
|
public sealed class EventStreamService(
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
IOptions<GatewayOptions> options,
|
||||||
|
MxAccessGrpcMapper mapper,
|
||||||
|
GatewayMetrics metrics,
|
||||||
|
ILogger<EventStreamService> logger) : IEventStreamService
|
||||||
|
{
|
||||||
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!sessionManager.TryGetSession(request.SessionId, out GatewaySession session))
|
||||||
|
{
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.SessionNotFound,
|
||||||
|
$"Session {request.SessionId} was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using IDisposable subscriber = session.AttachEventSubscriber(
|
||||||
|
options.Value.Sessions.AllowMultipleEventSubscribers);
|
||||||
|
using CancellationTokenSource streamCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
|
||||||
|
int streamQueueDepth = 0;
|
||||||
|
Channel<MxEvent> eventQueue = Channel.CreateBounded<MxEvent>(
|
||||||
|
new BoundedChannelOptions(options.Value.Events.QueueCapacity)
|
||||||
|
{
|
||||||
|
SingleReader = true,
|
||||||
|
SingleWriter = true,
|
||||||
|
FullMode = BoundedChannelFullMode.Wait,
|
||||||
|
AllowSynchronousContinuations = false,
|
||||||
|
});
|
||||||
|
Task producerTask = ProduceEventsAsync(
|
||||||
|
session,
|
||||||
|
request.AfterWorkerSequence,
|
||||||
|
eventQueue.Writer,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
int depth = Interlocked.Increment(ref streamQueueDepth);
|
||||||
|
metrics.SetEventQueueDepth(depth);
|
||||||
|
},
|
||||||
|
streamCts.Token);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (MxEvent mxEvent in eventQueue.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
int depth = Math.Max(0, Interlocked.Decrement(ref streamQueueDepth));
|
||||||
|
metrics.SetEventQueueDepth(depth);
|
||||||
|
yield return mxEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
await producerTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await streamCts.CancelAsync().ConfigureAwait(false);
|
||||||
|
subscriber.Dispose();
|
||||||
|
metrics.StreamDisconnected("Detached");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await producerTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (streamCts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogDebug(
|
||||||
|
exception,
|
||||||
|
"Event stream producer stopped for session {SessionId}.",
|
||||||
|
request.SessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProduceEventsAsync(
|
||||||
|
GatewaySession session,
|
||||||
|
ulong afterWorkerSequence,
|
||||||
|
ChannelWriter<MxEvent> writer,
|
||||||
|
Action eventQueued,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (WorkerEvent workerEvent in session
|
||||||
|
.ReadEventsAsync(cancellationToken)
|
||||||
|
.WithCancellation(cancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
MxEvent publicEvent = mapper.MapEvent(workerEvent);
|
||||||
|
if (publicEvent.WorkerSequence <= afterWorkerSequence)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!writer.TryWrite(publicEvent))
|
||||||
|
{
|
||||||
|
string message = $"Session {session.SessionId} event stream queue overflowed.";
|
||||||
|
session.MarkFaulted(message);
|
||||||
|
metrics.QueueOverflow("grpc-event-stream");
|
||||||
|
metrics.Fault(SessionManagerErrorCode.EventQueueOverflow.ToString());
|
||||||
|
writer.TryComplete(new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.EventQueueOverflow,
|
||||||
|
message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
eventQueued();
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.TryComplete();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
writer.TryComplete();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
if (exception is WorkerClientException)
|
||||||
|
{
|
||||||
|
session.MarkFaulted(exception.Message);
|
||||||
|
metrics.Fault(WorkerClientErrorCode.WorkerFaulted.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.TryComplete(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Grpc;
|
||||||
|
|
||||||
|
public interface IEventStreamService
|
||||||
|
{
|
||||||
|
IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Security.Authorization;
|
||||||
|
using MxGateway.Server.Sessions;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Grpc;
|
||||||
|
|
||||||
|
public sealed class MxAccessGatewayService(
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
IGatewayRequestIdentityAccessor identityAccessor,
|
||||||
|
MxAccessGrpcRequestValidator requestValidator,
|
||||||
|
MxAccessGrpcMapper mapper,
|
||||||
|
IEventStreamService eventStreamService,
|
||||||
|
ILogger<MxAccessGatewayService> logger) : MxAccessGateway.MxAccessGatewayBase
|
||||||
|
{
|
||||||
|
public override async Task<OpenSessionReply> OpenSession(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
requestValidator.ValidateOpenSession(request);
|
||||||
|
GatewaySession session = await sessionManager
|
||||||
|
.OpenSessionAsync(
|
||||||
|
SessionOpenRequest.FromContract(request),
|
||||||
|
ResolveClientIdentity(),
|
||||||
|
context.CancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
OpenSessionReply reply = new()
|
||||||
|
{
|
||||||
|
SessionId = session.SessionId,
|
||||||
|
BackendName = session.BackendName,
|
||||||
|
WorkerProcessId = session.WorkerProcessId ?? 0,
|
||||||
|
WorkerProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||||
|
DefaultCommandTimeout = Google.Protobuf.WellKnownTypes.Duration.FromTimeSpan(session.CommandTimeout),
|
||||||
|
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||||
|
};
|
||||||
|
reply.Capabilities.Add("unary-open-session");
|
||||||
|
reply.Capabilities.Add("unary-close-session");
|
||||||
|
reply.Capabilities.Add("unary-invoke");
|
||||||
|
reply.Capabilities.Add("server-stream-events");
|
||||||
|
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not RpcException)
|
||||||
|
{
|
||||||
|
throw MapException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<CloseSessionReply> CloseSession(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
requestValidator.ValidateCloseSession(request);
|
||||||
|
SessionCloseResult result = await sessionManager
|
||||||
|
.CloseSessionAsync(request.SessionId, context.CancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new CloseSessionReply
|
||||||
|
{
|
||||||
|
SessionId = result.SessionId,
|
||||||
|
FinalState = result.FinalState,
|
||||||
|
ProtocolStatus = MxAccessGrpcMapper.Ok(result.AlreadyClosed ? "Session was already closed." : "Session closed."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not RpcException)
|
||||||
|
{
|
||||||
|
throw MapException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<MxCommandReply> Invoke(
|
||||||
|
MxCommandRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
requestValidator.ValidateInvoke(request);
|
||||||
|
WorkerCommand workerCommand = mapper.MapCommand(request);
|
||||||
|
WorkerCommandReply workerReply = await sessionManager
|
||||||
|
.InvokeAsync(request.SessionId, workerCommand, context.CancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return mapper.MapCommandReply(workerReply);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not RpcException)
|
||||||
|
{
|
||||||
|
throw MapException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task StreamEvents(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
IServerStreamWriter<MxEvent> responseStream,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
requestValidator.ValidateStreamEvents(request);
|
||||||
|
await foreach (MxEvent publicEvent in eventStreamService
|
||||||
|
.StreamEventsAsync(request, context.CancellationToken)
|
||||||
|
.WithCancellation(context.CancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await responseStream.WriteAsync(publicEvent).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not RpcException)
|
||||||
|
{
|
||||||
|
throw MapException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ResolveClientIdentity()
|
||||||
|
{
|
||||||
|
return identityAccessor.Current?.DisplayName ?? identityAccessor.Current?.KeyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RpcException MapException(Exception exception)
|
||||||
|
{
|
||||||
|
if (exception is OperationCanceledException)
|
||||||
|
{
|
||||||
|
return new RpcException(new Status(StatusCode.Cancelled, "gRPC request was canceled."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exception is SessionManagerException sessionException)
|
||||||
|
{
|
||||||
|
return MapSessionException(sessionException);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exception is WorkerClientException workerClientException)
|
||||||
|
{
|
||||||
|
return MapWorkerClientException(workerClientException);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogWarning(exception, "Public gRPC request failed.");
|
||||||
|
return new RpcException(new Status(StatusCode.Unavailable, "Gateway request failed before an MXAccess reply was available."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RpcException MapSessionException(SessionManagerException exception)
|
||||||
|
{
|
||||||
|
StatusCode statusCode = exception.ErrorCode switch
|
||||||
|
{
|
||||||
|
SessionManagerErrorCode.SessionNotFound => StatusCode.NotFound,
|
||||||
|
SessionManagerErrorCode.SessionNotReady => StatusCode.FailedPrecondition,
|
||||||
|
SessionManagerErrorCode.EventSubscriberAlreadyActive => StatusCode.ResourceExhausted,
|
||||||
|
SessionManagerErrorCode.EventQueueOverflow => StatusCode.ResourceExhausted,
|
||||||
|
SessionManagerErrorCode.SessionLimitExceeded => StatusCode.ResourceExhausted,
|
||||||
|
SessionManagerErrorCode.OpenFailed => StatusCode.Unavailable,
|
||||||
|
SessionManagerErrorCode.CloseFailed => StatusCode.Unavailable,
|
||||||
|
_ => StatusCode.Unavailable,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new RpcException(new Status(statusCode, exception.Message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RpcException MapWorkerClientException(WorkerClientException exception)
|
||||||
|
{
|
||||||
|
StatusCode statusCode = exception.ErrorCode switch
|
||||||
|
{
|
||||||
|
WorkerClientErrorCode.CommandTimeout => StatusCode.DeadlineExceeded,
|
||||||
|
WorkerClientErrorCode.GatewayShutdown => StatusCode.Cancelled,
|
||||||
|
WorkerClientErrorCode.InvalidState => StatusCode.FailedPrecondition,
|
||||||
|
WorkerClientErrorCode.ProtocolViolation => StatusCode.Internal,
|
||||||
|
_ => StatusCode.Unavailable,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new RpcException(new Status(statusCode, exception.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Grpc;
|
||||||
|
|
||||||
|
public sealed class MxAccessGrpcMapper
|
||||||
|
{
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public MxAccessGrpcMapper(TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkerCommand MapCommand(MxCommandRequest request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ArgumentNullException.ThrowIfNull(request.Command);
|
||||||
|
|
||||||
|
return new WorkerCommand
|
||||||
|
{
|
||||||
|
Command = request.Command.Clone(),
|
||||||
|
EnqueueTimestamp = Timestamp.FromDateTimeOffset(_timeProvider.GetUtcNow()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxCommandReply MapCommandReply(WorkerCommandReply reply)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(reply);
|
||||||
|
|
||||||
|
if (reply.Reply is null)
|
||||||
|
{
|
||||||
|
return new MxCommandReply
|
||||||
|
{
|
||||||
|
ProtocolStatus = ProtocolViolation("Worker command reply did not contain a public reply payload."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.Reply.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxEvent MapEvent(WorkerEvent workerEvent)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(workerEvent);
|
||||||
|
|
||||||
|
return workerEvent.Event?.Clone() ?? new MxEvent
|
||||||
|
{
|
||||||
|
Family = MxEventFamily.Unspecified,
|
||||||
|
RawStatus = "Worker event did not contain a public event payload.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProtocolStatus Ok(string message = "OK")
|
||||||
|
{
|
||||||
|
return new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.Ok,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProtocolStatus InvalidRequest(string message)
|
||||||
|
{
|
||||||
|
return new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.InvalidRequest,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProtocolStatus SessionNotFound(string message)
|
||||||
|
{
|
||||||
|
return new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.SessionNotFound,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProtocolStatus SessionNotReady(string message)
|
||||||
|
{
|
||||||
|
return new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.SessionNotReady,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProtocolStatus WorkerUnavailable(string message)
|
||||||
|
{
|
||||||
|
return new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.WorkerUnavailable,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProtocolStatus Timeout(string message)
|
||||||
|
{
|
||||||
|
return new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.Timeout,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProtocolStatus Canceled(string message)
|
||||||
|
{
|
||||||
|
return new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.Canceled,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProtocolStatus ProtocolViolation(string message)
|
||||||
|
{
|
||||||
|
return new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.ProtocolViolation,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Grpc;
|
||||||
|
|
||||||
|
public sealed class MxAccessGrpcRequestValidator
|
||||||
|
{
|
||||||
|
public void ValidateOpenSession(OpenSessionRequest request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
if (request.CommandTimeout is not null && request.CommandTimeout.ToTimeSpan() <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw InvalidArgument("Command timeout must be greater than zero when provided.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ValidateCloseSession(CloseSessionRequest request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RequireSessionId(request.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ValidateStreamEvents(StreamEventsRequest request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RequireSessionId(request.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ValidateInvoke(MxCommandRequest request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RequireSessionId(request.SessionId);
|
||||||
|
|
||||||
|
if (request.Command is null)
|
||||||
|
{
|
||||||
|
throw InvalidArgument("Invoke requires a command payload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Command.Kind is MxCommandKind.Unspecified)
|
||||||
|
{
|
||||||
|
throw InvalidArgument("Invoke requires a command kind.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateCommandPayload(request.Command);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RequireSessionId(string sessionId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
throw InvalidArgument("Session id is required.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateCommandPayload(MxCommand command)
|
||||||
|
{
|
||||||
|
MxCommand.PayloadOneofCase expectedPayload = ExpectedPayload(command.Kind);
|
||||||
|
if (command.PayloadCase != expectedPayload)
|
||||||
|
{
|
||||||
|
throw InvalidArgument(
|
||||||
|
$"Command kind {command.Kind} requires payload {expectedPayload} but received {command.PayloadCase}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommand.PayloadOneofCase ExpectedPayload(MxCommandKind kind)
|
||||||
|
{
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
MxCommandKind.Register => MxCommand.PayloadOneofCase.Register,
|
||||||
|
MxCommandKind.Unregister => MxCommand.PayloadOneofCase.Unregister,
|
||||||
|
MxCommandKind.AddItem => MxCommand.PayloadOneofCase.AddItem,
|
||||||
|
MxCommandKind.AddItem2 => MxCommand.PayloadOneofCase.AddItem2,
|
||||||
|
MxCommandKind.RemoveItem => MxCommand.PayloadOneofCase.RemoveItem,
|
||||||
|
MxCommandKind.Advise => MxCommand.PayloadOneofCase.Advise,
|
||||||
|
MxCommandKind.UnAdvise => MxCommand.PayloadOneofCase.UnAdvise,
|
||||||
|
MxCommandKind.AdviseSupervisory => MxCommand.PayloadOneofCase.AdviseSupervisory,
|
||||||
|
MxCommandKind.AddBufferedItem => MxCommand.PayloadOneofCase.AddBufferedItem,
|
||||||
|
MxCommandKind.SetBufferedUpdateInterval => MxCommand.PayloadOneofCase.SetBufferedUpdateInterval,
|
||||||
|
MxCommandKind.Suspend => MxCommand.PayloadOneofCase.Suspend,
|
||||||
|
MxCommandKind.Activate => MxCommand.PayloadOneofCase.Activate,
|
||||||
|
MxCommandKind.Write => MxCommand.PayloadOneofCase.Write,
|
||||||
|
MxCommandKind.Write2 => MxCommand.PayloadOneofCase.Write2,
|
||||||
|
MxCommandKind.WriteSecured => MxCommand.PayloadOneofCase.WriteSecured,
|
||||||
|
MxCommandKind.WriteSecured2 => MxCommand.PayloadOneofCase.WriteSecured2,
|
||||||
|
MxCommandKind.AuthenticateUser => MxCommand.PayloadOneofCase.AuthenticateUser,
|
||||||
|
MxCommandKind.ArchestraUserToId => MxCommand.PayloadOneofCase.ArchestraUserToId,
|
||||||
|
MxCommandKind.Ping => MxCommand.PayloadOneofCase.Ping,
|
||||||
|
MxCommandKind.GetSessionState => MxCommand.PayloadOneofCase.GetSessionState,
|
||||||
|
MxCommandKind.GetWorkerInfo => MxCommand.PayloadOneofCase.GetWorkerInfo,
|
||||||
|
MxCommandKind.DrainEvents => MxCommand.PayloadOneofCase.DrainEvents,
|
||||||
|
MxCommandKind.ShutdownWorker => MxCommand.PayloadOneofCase.ShutdownWorker,
|
||||||
|
_ => MxCommand.PayloadOneofCase.None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RpcException InvalidArgument(string detail)
|
||||||
|
{
|
||||||
|
return new RpcException(new Status(StatusCode.InvalidArgument, detail));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public sealed class GatewaySession
|
||||||
|
{
|
||||||
|
private readonly object _syncRoot = new();
|
||||||
|
private readonly SemaphoreSlim _closeLock = new(1, 1);
|
||||||
|
private IWorkerClient? _workerClient;
|
||||||
|
private SessionState _state = SessionState.Creating;
|
||||||
|
private string? _finalFault;
|
||||||
|
private DateTimeOffset _lastClientActivityAt;
|
||||||
|
private DateTimeOffset? _leaseExpiresAt;
|
||||||
|
private bool _closeStarted;
|
||||||
|
private int _activeEventSubscriberCount;
|
||||||
|
|
||||||
|
public GatewaySession(
|
||||||
|
string sessionId,
|
||||||
|
string backendName,
|
||||||
|
string pipeName,
|
||||||
|
string nonce,
|
||||||
|
string? clientIdentity,
|
||||||
|
string? clientSessionName,
|
||||||
|
string? clientCorrelationId,
|
||||||
|
TimeSpan commandTimeout,
|
||||||
|
TimeSpan startupTimeout,
|
||||||
|
TimeSpan shutdownTimeout,
|
||||||
|
DateTimeOffset openedAt)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Session id is required.", nameof(sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(backendName))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Backend name is required.", nameof(backendName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(pipeName))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Pipe name is required.", nameof(pipeName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(nonce))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Nonce is required.", nameof(nonce));
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionId = sessionId;
|
||||||
|
BackendName = backendName;
|
||||||
|
PipeName = pipeName;
|
||||||
|
Nonce = nonce;
|
||||||
|
ClientIdentity = clientIdentity;
|
||||||
|
ClientSessionName = clientSessionName;
|
||||||
|
ClientCorrelationId = clientCorrelationId;
|
||||||
|
CommandTimeout = commandTimeout;
|
||||||
|
StartupTimeout = startupTimeout;
|
||||||
|
ShutdownTimeout = shutdownTimeout;
|
||||||
|
OpenedAt = openedAt;
|
||||||
|
_lastClientActivityAt = openedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SessionId { get; }
|
||||||
|
|
||||||
|
public string BackendName { get; }
|
||||||
|
|
||||||
|
public string PipeName { get; }
|
||||||
|
|
||||||
|
public string Nonce { get; }
|
||||||
|
|
||||||
|
public string? ClientIdentity { get; }
|
||||||
|
|
||||||
|
public string? ClientSessionName { get; }
|
||||||
|
|
||||||
|
public string? ClientCorrelationId { get; }
|
||||||
|
|
||||||
|
public TimeSpan CommandTimeout { get; }
|
||||||
|
|
||||||
|
public TimeSpan StartupTimeout { get; }
|
||||||
|
|
||||||
|
public TimeSpan ShutdownTimeout { get; }
|
||||||
|
|
||||||
|
public DateTimeOffset OpenedAt { get; }
|
||||||
|
|
||||||
|
public int? WorkerProcessId => _workerClient?.ProcessId;
|
||||||
|
|
||||||
|
public IWorkerClient? WorkerClient => _workerClient;
|
||||||
|
|
||||||
|
public SessionState State
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTimeOffset LastClientActivityAt
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _lastClientActivityAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTimeOffset? LeaseExpiresAt
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _leaseExpiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? FinalFault
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _finalFault;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ActiveEventSubscriberCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _activeEventSubscriberCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AttachWorkerClient(IWorkerClient workerClient)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(workerClient);
|
||||||
|
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
_workerClient = workerClient;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TransitionTo(SessionState nextState)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_state is SessionState.Closed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_state is SessionState.Faulted && nextState is not SessionState.Closed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = nextState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkReady()
|
||||||
|
{
|
||||||
|
TransitionTo(SessionState.Ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkFaulted(string reason)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_state is SessionState.Closed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_finalFault = reason;
|
||||||
|
_state = SessionState.Faulted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TouchClientActivity(DateTimeOffset activityAt)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
_lastClientActivityAt = activityAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ExtendLease(DateTimeOffset leaseExpiresAt)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
_leaseExpiresAt = leaseExpiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsLeaseExpired(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _leaseExpiresAt is not null && _leaseExpiresAt <= now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDisposable AttachEventSubscriber(bool allowMultipleSubscribers)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_state != SessionState.Ready || _workerClient?.State != WorkerClientState.Ready)
|
||||||
|
{
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.SessionNotReady,
|
||||||
|
$"Session {SessionId} is not ready for event streaming. Current state is {_state}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowMultipleSubscribers && _activeEventSubscriberCount > 0)
|
||||||
|
{
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.EventSubscriberAlreadyActive,
|
||||||
|
$"Session {SessionId} already has an active event stream subscriber.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeEventSubscriberCount++;
|
||||||
|
return new EventSubscriberLease(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
WorkerCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
IWorkerClient workerClient = GetReadyWorkerClient();
|
||||||
|
TouchClientActivity(DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
return await workerClient.InvokeAsync(command, CommandTimeout, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
IWorkerClient workerClient = GetReadyWorkerClient();
|
||||||
|
TouchClientActivity(DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
return workerClient.ReadEventsAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SessionCloseResult> CloseAsync(
|
||||||
|
string reason,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_state is SessionState.Closed)
|
||||||
|
{
|
||||||
|
return new SessionCloseResult(SessionId, SessionState.Closed, AlreadyClosed: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool alreadyClosing = _closeStarted;
|
||||||
|
_closeStarted = true;
|
||||||
|
_state = SessionState.Closing;
|
||||||
|
|
||||||
|
if (_workerClient is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _workerClient.ShutdownAsync(ShutdownTimeout, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_workerClient.Kill(reason);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = SessionState.Closed;
|
||||||
|
return new SessionCloseResult(SessionId, SessionState.Closed, alreadyClosing);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_closeLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void KillWorker(string reason)
|
||||||
|
{
|
||||||
|
_workerClient?.Kill(reason);
|
||||||
|
TransitionTo(SessionState.Closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_closeLock.Dispose();
|
||||||
|
if (_workerClient is not null)
|
||||||
|
{
|
||||||
|
await _workerClient.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IWorkerClient GetReadyWorkerClient()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_state != SessionState.Ready || _workerClient?.State != WorkerClientState.Ready)
|
||||||
|
{
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.SessionNotReady,
|
||||||
|
$"Session {SessionId} is not ready. Current state is {_state}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return _workerClient;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DetachEventSubscriber()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_activeEventSubscriberCount > 0)
|
||||||
|
{
|
||||||
|
_activeEventSubscriberCount--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class EventSubscriberLease(GatewaySession session) : IDisposable
|
||||||
|
{
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.DetachEventSubscriber();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public interface ISessionManager
|
||||||
|
{
|
||||||
|
Task<GatewaySession> OpenSessionAsync(
|
||||||
|
SessionOpenRequest request,
|
||||||
|
string? clientIdentity,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
bool TryGetSession(
|
||||||
|
string sessionId,
|
||||||
|
out GatewaySession session);
|
||||||
|
|
||||||
|
Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
string sessionId,
|
||||||
|
WorkerCommand command,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<SessionCloseResult> CloseSessionAsync(
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<int> CloseExpiredLeasesAsync(
|
||||||
|
DateTimeOffset now,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task ShutdownAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public interface ISessionRegistry
|
||||||
|
{
|
||||||
|
int Count { get; }
|
||||||
|
|
||||||
|
int ActiveCount { get; }
|
||||||
|
|
||||||
|
bool TryAdd(GatewaySession session);
|
||||||
|
|
||||||
|
bool TryGet(string sessionId, out GatewaySession session);
|
||||||
|
|
||||||
|
bool TryRemove(string sessionId, out GatewaySession session);
|
||||||
|
|
||||||
|
IReadOnlyCollection<GatewaySession> Snapshot();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public interface ISessionWorkerClientFactory
|
||||||
|
{
|
||||||
|
Task<MxGateway.Server.Workers.IWorkerClient> CreateAsync(
|
||||||
|
GatewaySession session,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public sealed record SessionCloseResult(
|
||||||
|
string SessionId,
|
||||||
|
SessionState FinalState,
|
||||||
|
bool AlreadyClosed);
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Contracts;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Metrics;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public sealed class SessionManager : ISessionManager
|
||||||
|
{
|
||||||
|
public const string DefaultCloseReason = "client-close";
|
||||||
|
public const string GatewayShutdownReason = "gateway-shutdown";
|
||||||
|
public const string LeaseExpiredReason = "lease-expired";
|
||||||
|
|
||||||
|
private readonly ISessionRegistry _registry;
|
||||||
|
private readonly ISessionWorkerClientFactory _workerClientFactory;
|
||||||
|
private readonly GatewayMetrics _metrics;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly ILogger<SessionManager> _logger;
|
||||||
|
private readonly GatewayOptions _options;
|
||||||
|
|
||||||
|
public SessionManager(
|
||||||
|
ISessionRegistry registry,
|
||||||
|
ISessionWorkerClientFactory workerClientFactory,
|
||||||
|
IOptions<GatewayOptions> options,
|
||||||
|
GatewayMetrics metrics,
|
||||||
|
TimeProvider? timeProvider = null,
|
||||||
|
ILogger<SessionManager>? logger = null)
|
||||||
|
{
|
||||||
|
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||||
|
_workerClientFactory = workerClientFactory ?? throw new ArgumentNullException(nameof(workerClientFactory));
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_logger = logger ?? NullLogger<SessionManager>.Instance;
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GatewaySession> OpenSessionAsync(
|
||||||
|
SessionOpenRequest request,
|
||||||
|
string? clientIdentity,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
EnsureSessionCapacity();
|
||||||
|
|
||||||
|
GatewaySession session = CreateSession(request, clientIdentity);
|
||||||
|
if (!_registry.TryAdd(session))
|
||||||
|
{
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.OpenFailed,
|
||||||
|
$"Session id collision while opening session {session.SessionId}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
session.TransitionTo(SessionState.StartingWorker);
|
||||||
|
IWorkerClient workerClient = await _workerClientFactory
|
||||||
|
.CreateAsync(session, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
session.AttachWorkerClient(workerClient);
|
||||||
|
session.MarkReady();
|
||||||
|
_metrics.SessionOpened();
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
session.MarkFaulted(exception.Message);
|
||||||
|
_registry.TryRemove(session.SessionId, out _);
|
||||||
|
await session.DisposeAsync().ConfigureAwait(false);
|
||||||
|
_metrics.Fault(SessionManagerErrorCode.OpenFailed.ToString());
|
||||||
|
_logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Failed to open gateway session {SessionId}.",
|
||||||
|
session.SessionId);
|
||||||
|
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.OpenFailed,
|
||||||
|
$"Failed to open session {session.SessionId}.",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetSession(
|
||||||
|
string sessionId,
|
||||||
|
out GatewaySession session)
|
||||||
|
{
|
||||||
|
return _registry.TryGet(sessionId, out session);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
string sessionId,
|
||||||
|
WorkerCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
GatewaySession session = GetRequiredSession(sessionId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await session.InvokeAsync(command, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (SessionManagerException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
if (session.WorkerClient?.State == WorkerClientState.Faulted)
|
||||||
|
{
|
||||||
|
session.MarkFaulted(exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
GatewaySession session = GetRequiredSession(sessionId);
|
||||||
|
|
||||||
|
return session.ReadEventsAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SessionCloseResult> CloseSessionAsync(
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
GatewaySession session = GetRequiredSession(sessionId);
|
||||||
|
SessionCloseResult result = await CloseSessionCoreAsync(
|
||||||
|
session,
|
||||||
|
DefaultCloseReason,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CloseExpiredLeasesAsync(
|
||||||
|
DateTimeOffset now,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
int closedCount = 0;
|
||||||
|
foreach (GatewaySession session in _registry.Snapshot())
|
||||||
|
{
|
||||||
|
if (!session.IsLeaseExpired(now))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CloseSessionCoreAsync(session, LeaseExpiredReason, cancellationToken).ConfigureAwait(false);
|
||||||
|
closedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return closedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (GatewaySession session in _registry.Snapshot())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CloseSessionCoreAsync(session, GatewayShutdownReason, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Graceful shutdown failed for session {SessionId}; killing worker.",
|
||||||
|
session.SessionId);
|
||||||
|
session.KillWorker(GatewayShutdownReason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SessionCloseResult> CloseSessionCoreAsync(
|
||||||
|
GatewaySession session,
|
||||||
|
string reason,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
bool wasClosed = session.State == SessionState.Closed;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SessionCloseResult result = await session.CloseAsync(reason, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!wasClosed && !result.AlreadyClosed)
|
||||||
|
{
|
||||||
|
_metrics.SessionClosed();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
session.MarkFaulted(exception.Message);
|
||||||
|
_metrics.Fault(SessionManagerErrorCode.CloseFailed.ToString());
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.CloseFailed,
|
||||||
|
$"Failed to close session {session.SessionId}.",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private GatewaySession GetRequiredSession(string sessionId)
|
||||||
|
{
|
||||||
|
if (!_registry.TryGet(sessionId, out GatewaySession session))
|
||||||
|
{
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.SessionNotFound,
|
||||||
|
$"Session {sessionId} was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureSessionCapacity()
|
||||||
|
{
|
||||||
|
if (_registry.ActiveCount >= _options.Sessions.MaxSessions)
|
||||||
|
{
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.SessionLimitExceeded,
|
||||||
|
$"Gateway session limit {_options.Sessions.MaxSessions} has been reached.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private GatewaySession CreateSession(
|
||||||
|
SessionOpenRequest request,
|
||||||
|
string? clientIdentity)
|
||||||
|
{
|
||||||
|
string sessionId = CreateSessionId();
|
||||||
|
string backendName = string.IsNullOrWhiteSpace(request.RequestedBackend)
|
||||||
|
? GatewayContractInfo.DefaultBackendName
|
||||||
|
: request.RequestedBackend!;
|
||||||
|
TimeSpan commandTimeout = ResolveCommandTimeout(request.CommandTimeout);
|
||||||
|
TimeSpan startupTimeout = TimeSpan.FromSeconds(_options.Worker.StartupTimeoutSeconds);
|
||||||
|
TimeSpan shutdownTimeout = TimeSpan.FromSeconds(_options.Worker.ShutdownTimeoutSeconds);
|
||||||
|
string pipeName = $"mxaccess-gateway-{Environment.ProcessId}-{sessionId}";
|
||||||
|
string nonce = CreateNonce();
|
||||||
|
DateTimeOffset openedAt = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
return new GatewaySession(
|
||||||
|
sessionId,
|
||||||
|
backendName,
|
||||||
|
pipeName,
|
||||||
|
nonce,
|
||||||
|
clientIdentity,
|
||||||
|
request.ClientSessionName,
|
||||||
|
request.ClientCorrelationId,
|
||||||
|
commandTimeout,
|
||||||
|
startupTimeout,
|
||||||
|
shutdownTimeout,
|
||||||
|
openedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimeSpan ResolveCommandTimeout(Duration? requestedTimeout)
|
||||||
|
{
|
||||||
|
if (requestedTimeout is null)
|
||||||
|
{
|
||||||
|
return TimeSpan.FromSeconds(_options.Sessions.DefaultCommandTimeoutSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeSpan timeout = requestedTimeout.ToTimeSpan();
|
||||||
|
return timeout <= TimeSpan.Zero
|
||||||
|
? TimeSpan.FromSeconds(_options.Sessions.DefaultCommandTimeoutSeconds)
|
||||||
|
: timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateSessionId()
|
||||||
|
{
|
||||||
|
return $"session-{Guid.NewGuid():N}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateNonce()
|
||||||
|
{
|
||||||
|
Span<byte> bytes = stackalloc byte[32];
|
||||||
|
RandomNumberGenerator.Fill(bytes);
|
||||||
|
|
||||||
|
return Convert.ToBase64String(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public enum SessionManagerErrorCode
|
||||||
|
{
|
||||||
|
SessionNotFound,
|
||||||
|
SessionNotReady,
|
||||||
|
EventSubscriberAlreadyActive,
|
||||||
|
EventQueueOverflow,
|
||||||
|
SessionLimitExceeded,
|
||||||
|
OpenFailed,
|
||||||
|
CloseFailed,
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public sealed class SessionManagerException : Exception
|
||||||
|
{
|
||||||
|
public SessionManagerException(
|
||||||
|
SessionManagerErrorCode errorCode,
|
||||||
|
string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
ErrorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionManagerException(
|
||||||
|
SessionManagerErrorCode errorCode,
|
||||||
|
string message,
|
||||||
|
Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
ErrorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionManagerErrorCode ErrorCode { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public sealed record SessionOpenRequest(
|
||||||
|
string? RequestedBackend,
|
||||||
|
string? ClientSessionName,
|
||||||
|
string? ClientCorrelationId,
|
||||||
|
Duration? CommandTimeout)
|
||||||
|
{
|
||||||
|
public static SessionOpenRequest FromContract(OpenSessionRequest request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
return new SessionOpenRequest(
|
||||||
|
request.RequestedBackend,
|
||||||
|
request.ClientSessionName,
|
||||||
|
request.ClientCorrelationId,
|
||||||
|
request.CommandTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public sealed class SessionRegistry : ISessionRegistry
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, GatewaySession> _sessions = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public int Count => _sessions.Count;
|
||||||
|
|
||||||
|
public int ActiveCount => _sessions.Values.Count(session => session.State is not SessionState.Closed);
|
||||||
|
|
||||||
|
public bool TryAdd(GatewaySession session)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(session);
|
||||||
|
|
||||||
|
return _sessions.TryAdd(session.SessionId, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGet(
|
||||||
|
string sessionId,
|
||||||
|
out GatewaySession session)
|
||||||
|
{
|
||||||
|
return _sessions.TryGetValue(sessionId, out session!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryRemove(
|
||||||
|
string sessionId,
|
||||||
|
out GatewaySession session)
|
||||||
|
{
|
||||||
|
return _sessions.TryRemove(sessionId, out session!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<GatewaySession> Snapshot()
|
||||||
|
{
|
||||||
|
return _sessions.Values.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public static class SessionServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddGatewaySessions(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<ISessionRegistry, SessionRegistry>();
|
||||||
|
services.AddSingleton<ISessionWorkerClientFactory, SessionWorkerClientFactory>();
|
||||||
|
services.AddSingleton<ISessionManager, SessionManager>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
using System.IO.Pipes;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Contracts;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Metrics;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory
|
||||||
|
{
|
||||||
|
private readonly IWorkerProcessLauncher _workerProcessLauncher;
|
||||||
|
private readonly GatewayMetrics _metrics;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
|
private readonly GatewayOptions _options;
|
||||||
|
|
||||||
|
public SessionWorkerClientFactory(
|
||||||
|
IWorkerProcessLauncher workerProcessLauncher,
|
||||||
|
IOptions<GatewayOptions> options,
|
||||||
|
GatewayMetrics metrics,
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_workerProcessLauncher = workerProcessLauncher ?? throw new ArgumentNullException(nameof(workerProcessLauncher));
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||||
|
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IWorkerClient> CreateAsync(
|
||||||
|
GatewaySession session,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(session);
|
||||||
|
|
||||||
|
NamedPipeServerStream? pipe = CreatePipe(session.PipeName);
|
||||||
|
WorkerProcessHandle? processHandle = null;
|
||||||
|
IWorkerClient? workerClient = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
session.TransitionTo(SessionState.StartingWorker);
|
||||||
|
processHandle = await _workerProcessLauncher
|
||||||
|
.LaunchAsync(
|
||||||
|
new WorkerProcessLaunchRequest(
|
||||||
|
session.SessionId,
|
||||||
|
session.PipeName,
|
||||||
|
GatewayContractInfo.WorkerProtocolVersion,
|
||||||
|
session.Nonce,
|
||||||
|
pipe),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
session.TransitionTo(SessionState.WaitingForPipe);
|
||||||
|
await WaitForPipeConnectionAsync(pipe, session.StartupTimeout, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
session.TransitionTo(SessionState.Handshaking);
|
||||||
|
WorkerFrameProtocolOptions frameOptions = new(
|
||||||
|
session.SessionId,
|
||||||
|
GatewayContractInfo.WorkerProtocolVersion,
|
||||||
|
_options.Worker.MaxMessageBytes);
|
||||||
|
WorkerClientConnection connection = new(
|
||||||
|
session.SessionId,
|
||||||
|
session.Nonce,
|
||||||
|
pipe,
|
||||||
|
frameOptions,
|
||||||
|
processHandle);
|
||||||
|
WorkerClientOptions clientOptions = new()
|
||||||
|
{
|
||||||
|
HeartbeatGrace = TimeSpan.FromSeconds(_options.Worker.HeartbeatGraceSeconds),
|
||||||
|
HeartbeatCheckInterval = TimeSpan.FromSeconds(_options.Worker.HeartbeatIntervalSeconds),
|
||||||
|
EventChannelCapacity = _options.Events.QueueCapacity,
|
||||||
|
};
|
||||||
|
|
||||||
|
workerClient = new WorkerClient(
|
||||||
|
connection,
|
||||||
|
clientOptions,
|
||||||
|
_metrics,
|
||||||
|
_timeProvider,
|
||||||
|
_loggerFactory.CreateLogger<WorkerClient>());
|
||||||
|
|
||||||
|
pipe = null;
|
||||||
|
processHandle = null;
|
||||||
|
|
||||||
|
session.TransitionTo(SessionState.InitializingWorker);
|
||||||
|
await workerClient.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return workerClient;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (workerClient is not null)
|
||||||
|
{
|
||||||
|
await workerClient.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (processHandle is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!processHandle.Process.HasExited)
|
||||||
|
{
|
||||||
|
processHandle.Process.Kill(entireProcessTree: true);
|
||||||
|
_metrics.WorkerKilled("OpenSessionFailed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
processHandle.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pipe?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NamedPipeServerStream CreatePipe(string pipeName)
|
||||||
|
{
|
||||||
|
return new NamedPipeServerStream(
|
||||||
|
pipeName,
|
||||||
|
PipeDirection.InOut,
|
||||||
|
maxNumberOfServerInstances: 1,
|
||||||
|
PipeTransmissionMode.Byte,
|
||||||
|
PipeOptions.Asynchronous);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForPipeConnectionAsync(
|
||||||
|
NamedPipeServerStream pipe,
|
||||||
|
TimeSpan startupTimeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeout.CancelAfter(startupTimeout);
|
||||||
|
await pipe.WaitForConnectionAsync(timeout.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
private WorkerClientState _state;
|
private WorkerClientState _state;
|
||||||
private DateTimeOffset _lastHeartbeatAt;
|
private DateTimeOffset _lastHeartbeatAt;
|
||||||
private int? _processId;
|
private int? _processId;
|
||||||
|
private int _eventQueueDepth;
|
||||||
private Task? _readLoopTask;
|
private Task? _readLoopTask;
|
||||||
private Task? _writeLoopTask;
|
private Task? _writeLoopTask;
|
||||||
private Task? _heartbeatLoopTask;
|
private Task? _heartbeatLoopTask;
|
||||||
@@ -197,6 +198,8 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
{
|
{
|
||||||
await foreach (WorkerEvent workerEvent in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
await foreach (WorkerEvent workerEvent in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
|
int queueDepth = Math.Max(0, Interlocked.Decrement(ref _eventQueueDepth));
|
||||||
|
_metrics?.SetEventQueueDepth(queueDepth);
|
||||||
yield return workerEvent;
|
yield return workerEvent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,11 +397,6 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
_metrics?.EventReceived(SessionId, workerEvent.Event.Family.ToString());
|
_metrics?.EventReceived(SessionId, workerEvent.Event.Family.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await _events.Writer.WaitToWriteAsync(cancellationToken).ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_events.Writer.TryWrite(workerEvent))
|
if (!_events.Writer.TryWrite(workerEvent))
|
||||||
{
|
{
|
||||||
_metrics?.QueueOverflow("worker-events");
|
_metrics?.QueueOverflow("worker-events");
|
||||||
@@ -406,7 +404,11 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
WorkerClientErrorCode.ProtocolViolation,
|
WorkerClientErrorCode.ProtocolViolation,
|
||||||
"Worker event channel rejected an event.",
|
"Worker event channel rejected an event.",
|
||||||
null);
|
null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int queueDepth = Interlocked.Increment(ref _eventQueueDepth);
|
||||||
|
_metrics?.SetEventQueueDepth(queueDepth);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CompleteCommand(WorkerEnvelope envelope)
|
private void CompleteCommand(WorkerEnvelope envelope)
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Dashboard;
|
||||||
|
using MxGateway.Server.Security.Authentication;
|
||||||
|
using MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.Gateway.Dashboard;
|
||||||
|
|
||||||
|
public sealed class DashboardAuthenticatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthenticateAsync_AdminKey_ReturnsCookiePrincipal()
|
||||||
|
{
|
||||||
|
FakeApiKeyVerifier verifier = new(SuccessWithScopes(GatewayScopes.Admin));
|
||||||
|
DashboardAuthenticator authenticator = CreateAuthenticator(verifier);
|
||||||
|
|
||||||
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
|
"mxgw_operator01_super-secret",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
Assert.NotNull(result.Principal);
|
||||||
|
Assert.Equal("operator01", result.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
||||||
|
Assert.Equal("Operator Key", result.Principal.FindFirst(ClaimTypes.Name)?.Value);
|
||||||
|
Assert.Contains(result.Principal.Claims, claim =>
|
||||||
|
claim.Type == DashboardAuthenticationDefaults.ScopeClaimType
|
||||||
|
&& claim.Value == GatewayScopes.Admin);
|
||||||
|
Assert.Equal("Bearer mxgw_operator01_super-secret", verifier.LastAuthorizationHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthenticateAsync_NonAdminKey_ReturnsFailureWithoutRawApiKey()
|
||||||
|
{
|
||||||
|
DashboardAuthenticator authenticator = CreateAuthenticator(new FakeApiKeyVerifier(
|
||||||
|
SuccessWithScopes(GatewayScopes.EventsRead)));
|
||||||
|
|
||||||
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
|
"mxgw_operator01_super-secret",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Succeeded);
|
||||||
|
Assert.Null(result.Principal);
|
||||||
|
Assert.DoesNotContain("super-secret", result.FailureMessage, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthenticateAsync_RequireAdminScopeFalse_AllowsAuthenticatedKey()
|
||||||
|
{
|
||||||
|
DashboardAuthenticator authenticator = CreateAuthenticator(
|
||||||
|
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)),
|
||||||
|
requireAdminScope: false);
|
||||||
|
|
||||||
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
|
"mxgw_operator01_secret",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
Assert.NotNull(result.Principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthenticateAsync_InvalidKey_ReturnsGenericFailure()
|
||||||
|
{
|
||||||
|
DashboardAuthenticator authenticator = CreateAuthenticator(new FakeApiKeyVerifier(
|
||||||
|
ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch)));
|
||||||
|
|
||||||
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
|
"mxgw_operator01_super-secret",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Succeeded);
|
||||||
|
Assert.DoesNotContain("super-secret", result.FailureMessage, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DashboardAuthenticator CreateAuthenticator(
|
||||||
|
IApiKeyVerifier verifier,
|
||||||
|
bool requireAdminScope = true)
|
||||||
|
{
|
||||||
|
return new DashboardAuthenticator(
|
||||||
|
verifier,
|
||||||
|
Options.Create(new GatewayOptions
|
||||||
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
|
{
|
||||||
|
RequireAdminScope = requireAdminScope
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApiKeyVerificationResult SuccessWithScopes(params string[] scopes)
|
||||||
|
{
|
||||||
|
return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
|
||||||
|
KeyId: "operator01",
|
||||||
|
KeyPrefix: "mxgw_operator01",
|
||||||
|
DisplayName: "Operator Key",
|
||||||
|
Scopes: new HashSet<string>(scopes, StringComparer.Ordinal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier
|
||||||
|
{
|
||||||
|
public string? LastAuthorizationHeader { get; private set; }
|
||||||
|
|
||||||
|
public Task<ApiKeyVerificationResult> VerifyAsync(
|
||||||
|
string? authorizationHeader,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastAuthorizationHeader = authorizationHeader;
|
||||||
|
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Dashboard;
|
||||||
|
using MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.Gateway.Dashboard;
|
||||||
|
|
||||||
|
public sealed class DashboardAuthorizationHandlerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_UnauthenticatedRemoteRequest_DoesNotSucceed()
|
||||||
|
{
|
||||||
|
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||||
|
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||||
|
IPAddress.Parse("10.0.0.5"),
|
||||||
|
allowAnonymousLocalhost: false);
|
||||||
|
|
||||||
|
Assert.False(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_AnonymousLocalhostAllowed_Succeeds()
|
||||||
|
{
|
||||||
|
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||||
|
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||||
|
IPAddress.Loopback,
|
||||||
|
allowAnonymousLocalhost: true);
|
||||||
|
|
||||||
|
Assert.True(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_AuthenticatedWithoutAdminScope_DoesNotSucceed()
|
||||||
|
{
|
||||||
|
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||||
|
CreatePrincipal(GatewayScopes.EventsRead),
|
||||||
|
IPAddress.Loopback,
|
||||||
|
allowAnonymousLocalhost: false);
|
||||||
|
|
||||||
|
Assert.False(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_AuthenticatedWithAdminScope_Succeeds()
|
||||||
|
{
|
||||||
|
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||||
|
CreatePrincipal(GatewayScopes.Admin),
|
||||||
|
IPAddress.Parse("10.0.0.5"),
|
||||||
|
allowAnonymousLocalhost: false);
|
||||||
|
|
||||||
|
Assert.True(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<AuthorizationHandlerContext> AuthorizeAsync(
|
||||||
|
ClaimsPrincipal principal,
|
||||||
|
IPAddress remoteAddress,
|
||||||
|
bool allowAnonymousLocalhost)
|
||||||
|
{
|
||||||
|
DashboardAuthorizationRequirement requirement = new();
|
||||||
|
DefaultHttpContext httpContext = new();
|
||||||
|
httpContext.Connection.RemoteIpAddress = remoteAddress;
|
||||||
|
DashboardAuthorizationHandler handler = new(
|
||||||
|
new HttpContextAccessor { HttpContext = httpContext },
|
||||||
|
Options.Create(new GatewayOptions
|
||||||
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
|
{
|
||||||
|
AllowAnonymousLocalhost = allowAnonymousLocalhost,
|
||||||
|
RequireAdminScope = true
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
AuthorizationHandlerContext context = new([requirement], principal, httpContext);
|
||||||
|
|
||||||
|
await handler.HandleAsync(context);
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal CreatePrincipal(string scope)
|
||||||
|
{
|
||||||
|
ClaimsIdentity identity = new(
|
||||||
|
[new Claim(DashboardAuthenticationDefaults.ScopeClaimType, scope)],
|
||||||
|
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
|
return new ClaimsPrincipal(identity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Server;
|
||||||
|
using MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.Gateway.Dashboard;
|
||||||
|
|
||||||
|
public sealed class DashboardCookieOptionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Build_ConfiguresSecureDashboardCookie()
|
||||||
|
{
|
||||||
|
WebApplication app = GatewayApplication.Build([]);
|
||||||
|
IOptionsMonitor<CookieAuthenticationOptions> optionsMonitor = app.Services
|
||||||
|
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
|
||||||
|
|
||||||
|
CookieAuthenticationOptions options = optionsMonitor.Get(
|
||||||
|
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
|
Assert.Equal(DashboardAuthenticationDefaults.CookieName, options.Cookie.Name);
|
||||||
|
Assert.True(options.Cookie.HttpOnly);
|
||||||
|
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
|
||||||
|
Assert.Equal(SameSiteMode.Strict, options.Cookie.SameSite);
|
||||||
|
Assert.Equal("/", options.Cookie.Path);
|
||||||
|
Assert.Equal("/dashboard/login", options.LoginPath);
|
||||||
|
Assert.Equal("/dashboard/logout", options.LogoutPath);
|
||||||
|
Assert.Equal("/dashboard/denied", options.AccessDeniedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Dashboard;
|
||||||
|
using MxGateway.Server.Metrics;
|
||||||
|
using MxGateway.Server.Sessions;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.Gateway.Dashboard;
|
||||||
|
|
||||||
|
public sealed class DashboardSnapshotServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetSnapshot_WhenRegistryEmpty_ReturnsEmptyOperationalState()
|
||||||
|
{
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics);
|
||||||
|
|
||||||
|
DashboardSnapshot snapshot = service.GetSnapshot();
|
||||||
|
|
||||||
|
Assert.Empty(snapshot.Sessions);
|
||||||
|
Assert.Empty(snapshot.Workers);
|
||||||
|
Assert.Empty(snapshot.Faults);
|
||||||
|
Assert.Contains(snapshot.Metrics, metric => metric.Name == "mxgateway.sessions.open" && metric.Value == 0);
|
||||||
|
Assert.Equal("Healthy", snapshot.GatewayStatus);
|
||||||
|
Assert.NotNull(snapshot.Configuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSnapshot_ProjectsActiveAndFaultedSessionsWorkersMetricsAndFaults()
|
||||||
|
{
|
||||||
|
SessionRegistry registry = new();
|
||||||
|
GatewaySession activeSession = CreateSession(
|
||||||
|
"session-active",
|
||||||
|
"client-one",
|
||||||
|
DateTimeOffset.Parse("2026-04-26T10:00:00Z"));
|
||||||
|
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"));
|
||||||
|
faultedSession.AttachWorkerClient(new FakeWorkerClient("session-faulted", 1202, WorkerClientState.Faulted));
|
||||||
|
faultedSession.MarkFaulted("worker pipe disconnected");
|
||||||
|
registry.TryAdd(activeSession);
|
||||||
|
registry.TryAdd(faultedSession);
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
metrics.SessionOpened();
|
||||||
|
metrics.SessionOpened();
|
||||||
|
metrics.CommandStarted("Register");
|
||||||
|
metrics.CommandFailed("Register", "WorkerFaulted", TimeSpan.FromMilliseconds(7));
|
||||||
|
metrics.EventReceived("session-active", "OnDataChange");
|
||||||
|
metrics.Fault("WorkerFaulted");
|
||||||
|
DashboardSnapshotService service = CreateService(registry, metrics);
|
||||||
|
|
||||||
|
DashboardSnapshot snapshot = service.GetSnapshot();
|
||||||
|
|
||||||
|
Assert.Equal(2, snapshot.Sessions.Count);
|
||||||
|
Assert.Equal("session-faulted", snapshot.Sessions[0].SessionId);
|
||||||
|
Assert.Equal(SessionState.Faulted, snapshot.Sessions[0].State);
|
||||||
|
Assert.Equal(2, snapshot.Workers.Count);
|
||||||
|
Assert.Contains(snapshot.Metrics, metric => metric.Name == "mxgateway.commands.started" && metric.Value == 1);
|
||||||
|
Assert.Contains(
|
||||||
|
snapshot.Metrics,
|
||||||
|
metric => metric.Name == "mxgateway.events.received"
|
||||||
|
&& metric.Dimension == "OnDataChange"
|
||||||
|
&& metric.Value == 1);
|
||||||
|
DashboardFaultSummary fault = Assert.Single(snapshot.Faults);
|
||||||
|
Assert.Equal("Worker", fault.Source);
|
||||||
|
Assert.Equal("session-faulted", fault.SessionId);
|
||||||
|
Assert.Equal("worker pipe disconnected", fault.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSnapshot_RedactsSecretsFromSessionAndFaultFields()
|
||||||
|
{
|
||||||
|
SessionRegistry registry = new();
|
||||||
|
GatewaySession session = CreateSession(
|
||||||
|
"session-redacted",
|
||||||
|
"Bearer mxgw_admin_super-secret",
|
||||||
|
DateTimeOffset.Parse("2026-04-26T10:00:00Z"),
|
||||||
|
clientSessionName: "password=hunter2",
|
||||||
|
clientCorrelationId: "token=abc123");
|
||||||
|
session.MarkFaulted("secret=credential-value");
|
||||||
|
registry.TryAdd(session);
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
DashboardSnapshotService service = CreateService(registry, metrics);
|
||||||
|
|
||||||
|
DashboardSnapshot snapshot = service.GetSnapshot();
|
||||||
|
|
||||||
|
DashboardSessionSummary summary = Assert.Single(snapshot.Sessions);
|
||||||
|
Assert.Equal("Bearer mxgw_admin_[redacted]", summary.ClientIdentity);
|
||||||
|
Assert.Equal("[redacted]", summary.ClientSessionName);
|
||||||
|
Assert.Equal("[redacted]", summary.ClientCorrelationId);
|
||||||
|
Assert.Equal("[redacted]", summary.LastFault);
|
||||||
|
Assert.Equal("[redacted]", Assert.Single(snapshot.Faults).Message);
|
||||||
|
Assert.Equal("[redacted]", snapshot.Configuration.Authentication.PepperSecretName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSnapshot_DoesNotMutateSessionOrWorkerState()
|
||||||
|
{
|
||||||
|
SessionRegistry registry = new();
|
||||||
|
GatewaySession session = CreateSession(
|
||||||
|
"session-active",
|
||||||
|
"client-one",
|
||||||
|
DateTimeOffset.Parse("2026-04-26T10:00:00Z"));
|
||||||
|
FakeWorkerClient workerClient = new("session-active", 1201, WorkerClientState.Ready);
|
||||||
|
session.AttachWorkerClient(workerClient);
|
||||||
|
session.MarkReady();
|
||||||
|
registry.TryAdd(session);
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
DashboardSnapshotService service = CreateService(registry, metrics);
|
||||||
|
|
||||||
|
service.GetSnapshot();
|
||||||
|
service.GetSnapshot();
|
||||||
|
|
||||||
|
Assert.Equal(1, registry.ActiveCount);
|
||||||
|
Assert.Equal(SessionState.Ready, session.State);
|
||||||
|
Assert.Equal(WorkerClientState.Ready, workerClient.State);
|
||||||
|
Assert.Equal(0, workerClient.StartCount);
|
||||||
|
Assert.Equal(0, workerClient.ShutdownCount);
|
||||||
|
Assert.Equal(0, workerClient.KillCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSnapshot_AppliesRecentSessionAndFaultLimits()
|
||||||
|
{
|
||||||
|
SessionRegistry registry = new();
|
||||||
|
GatewaySession olderSession = CreateSession(
|
||||||
|
"session-older",
|
||||||
|
"client-one",
|
||||||
|
DateTimeOffset.Parse("2026-04-26T10:00:00Z"));
|
||||||
|
GatewaySession newerSession = CreateSession(
|
||||||
|
"session-newer",
|
||||||
|
"client-two",
|
||||||
|
DateTimeOffset.Parse("2026-04-26T10:01:00Z"));
|
||||||
|
olderSession.MarkFaulted("older fault");
|
||||||
|
newerSession.MarkFaulted("newer fault");
|
||||||
|
registry.TryAdd(olderSession);
|
||||||
|
registry.TryAdd(newerSession);
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
DashboardSnapshotService service = CreateService(
|
||||||
|
registry,
|
||||||
|
metrics,
|
||||||
|
new GatewayOptions
|
||||||
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
|
{
|
||||||
|
SnapshotIntervalMilliseconds = 1,
|
||||||
|
RecentSessionLimit = 1,
|
||||||
|
RecentFaultLimit = 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
DashboardSnapshot snapshot = service.GetSnapshot();
|
||||||
|
|
||||||
|
Assert.Equal("session-newer", Assert.Single(snapshot.Sessions).SessionId);
|
||||||
|
Assert.Equal("session-newer", Assert.Single(snapshot.Faults).SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
|
||||||
|
{
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
DashboardSnapshotService service = CreateService(
|
||||||
|
new SessionRegistry(),
|
||||||
|
metrics,
|
||||||
|
new GatewayOptions
|
||||||
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
|
{
|
||||||
|
SnapshotIntervalMilliseconds = 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
|
||||||
|
.WatchSnapshotsAsync(cancellation.Token)
|
||||||
|
.GetAsyncEnumerator();
|
||||||
|
|
||||||
|
Assert.True(await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
|
||||||
|
await cancellation.CancelAsync();
|
||||||
|
bool hasNext = await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
Assert.False(hasNext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DashboardSnapshotService CreateService(
|
||||||
|
SessionRegistry registry,
|
||||||
|
GatewayMetrics metrics,
|
||||||
|
GatewayOptions? options = null)
|
||||||
|
{
|
||||||
|
GatewayOptions resolvedOptions = options ?? new GatewayOptions
|
||||||
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
|
{
|
||||||
|
SnapshotIntervalMilliseconds = 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
GatewayConfigurationProvider configurationProvider = new(Options.Create(resolvedOptions));
|
||||||
|
|
||||||
|
return new DashboardSnapshotService(
|
||||||
|
registry,
|
||||||
|
metrics,
|
||||||
|
configurationProvider,
|
||||||
|
Options.Create(resolvedOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GatewaySession CreateSession(
|
||||||
|
string sessionId,
|
||||||
|
string? clientIdentity,
|
||||||
|
DateTimeOffset openedAt,
|
||||||
|
string? clientSessionName = "test-session",
|
||||||
|
string? clientCorrelationId = "client-correlation")
|
||||||
|
{
|
||||||
|
return new GatewaySession(
|
||||||
|
sessionId,
|
||||||
|
"mxaccess",
|
||||||
|
$"mxaccess-gateway-1-{sessionId}",
|
||||||
|
"nonce",
|
||||||
|
clientIdentity,
|
||||||
|
clientSessionName,
|
||||||
|
clientCorrelationId,
|
||||||
|
TimeSpan.FromSeconds(30),
|
||||||
|
TimeSpan.FromSeconds(5),
|
||||||
|
TimeSpan.FromSeconds(5),
|
||||||
|
openedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeWorkerClient(
|
||||||
|
string sessionId,
|
||||||
|
int? processId,
|
||||||
|
WorkerClientState state) : IWorkerClient
|
||||||
|
{
|
||||||
|
public string SessionId { get; } = sessionId;
|
||||||
|
|
||||||
|
public int? ProcessId { get; } = processId;
|
||||||
|
|
||||||
|
public WorkerClientState State { get; private set; } = state;
|
||||||
|
|
||||||
|
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z");
|
||||||
|
|
||||||
|
public int StartCount { get; private set; }
|
||||||
|
|
||||||
|
public int ShutdownCount { get; private set; }
|
||||||
|
|
||||||
|
public int KillCount { get; private set; }
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
StartCount++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
WorkerCommand command,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return 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)
|
||||||
|
{
|
||||||
|
ShutdownCount++;
|
||||||
|
State = WorkerClientState.Closed;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Kill(string reason)
|
||||||
|
{
|
||||||
|
KillCount++;
|
||||||
|
State = WorkerClientState.Faulted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Grpc.Core;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Contracts;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Grpc;
|
||||||
|
using MxGateway.Server.Metrics;
|
||||||
|
using MxGateway.Server.Sessions;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.Gateway.Grpc;
|
||||||
|
|
||||||
|
public sealed class EventStreamServiceTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamEventsAsync_YieldsEventsInWorkerOrder()
|
||||||
|
{
|
||||||
|
FakeWorkerClient workerClient = new();
|
||||||
|
GatewaySession session = CreateReadySession(workerClient);
|
||||||
|
FakeSessionManager sessionManager = new(session);
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
EventStreamService service = CreateService(sessionManager, metrics: metrics);
|
||||||
|
workerClient.Events.Add(CreateWorkerEvent(sequence: 10, MxEventFamily.OnDataChange));
|
||||||
|
workerClient.Events.Add(CreateWorkerEvent(sequence: 11, MxEventFamily.OnWriteComplete));
|
||||||
|
workerClient.CompleteAfterConfiguredEvents = true;
|
||||||
|
|
||||||
|
List<MxEvent> events = await CollectEventsAsync(service, session.SessionId);
|
||||||
|
|
||||||
|
Assert.Equal([10UL, 11UL], events.Select(mxEvent => mxEvent.WorkerSequence).ToArray());
|
||||||
|
Assert.Equal(MxEventFamily.OnDataChange, events[0].Family);
|
||||||
|
Assert.Equal(MxEventFamily.OnWriteComplete, events[1].Family);
|
||||||
|
Assert.Equal(1, metrics.GetSnapshot().StreamDisconnects);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamEventsAsync_WhenSecondSubscriberStarts_RejectsClearly()
|
||||||
|
{
|
||||||
|
FakeWorkerClient workerClient = new();
|
||||||
|
GatewaySession session = CreateReadySession(workerClient);
|
||||||
|
EventStreamService service = CreateService(new FakeSessionManager(session));
|
||||||
|
using CancellationTokenSource firstSubscriberCancellation = new();
|
||||||
|
await using IAsyncEnumerator<MxEvent> firstSubscriber = service
|
||||||
|
.StreamEventsAsync(CreateRequest(session.SessionId), firstSubscriberCancellation.Token)
|
||||||
|
.GetAsyncEnumerator(firstSubscriberCancellation.Token);
|
||||||
|
Task<bool> firstMoveTask = firstSubscriber.MoveNextAsync().AsTask();
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 1);
|
||||||
|
await using IAsyncEnumerator<MxEvent> secondSubscriber = service
|
||||||
|
.StreamEventsAsync(CreateRequest(session.SessionId), CancellationToken.None)
|
||||||
|
.GetAsyncEnumerator();
|
||||||
|
|
||||||
|
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
|
||||||
|
async () => await secondSubscriber.MoveNextAsync().AsTask().WaitAsync(TestTimeout));
|
||||||
|
|
||||||
|
Assert.Equal(SessionManagerErrorCode.EventSubscriberAlreadyActive, exception.ErrorCode);
|
||||||
|
await firstSubscriberCancellation.CancelAsync();
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||||
|
async () => await firstMoveTask.WaitAsync(TestTimeout));
|
||||||
|
await firstSubscriber.DisposeAsync();
|
||||||
|
await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamEventsAsync_WhenCanceled_DetachesSubscriber()
|
||||||
|
{
|
||||||
|
FakeWorkerClient workerClient = new();
|
||||||
|
GatewaySession session = CreateReadySession(workerClient);
|
||||||
|
EventStreamService service = CreateService(new FakeSessionManager(session));
|
||||||
|
using CancellationTokenSource cancellationTokenSource = new();
|
||||||
|
await using IAsyncEnumerator<MxEvent> subscriber = service
|
||||||
|
.StreamEventsAsync(CreateRequest(session.SessionId), cancellationTokenSource.Token)
|
||||||
|
.GetAsyncEnumerator(cancellationTokenSource.Token);
|
||||||
|
Task<bool> moveTask = subscriber.MoveNextAsync().AsTask();
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 1);
|
||||||
|
await cancellationTokenSource.CancelAsync();
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||||
|
async () => await moveTask.WaitAsync(TestTimeout));
|
||||||
|
await subscriber.DisposeAsync();
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamEventsAsync_WhenStreamQueueOverflows_FaultsSessionAndReportsOverflow()
|
||||||
|
{
|
||||||
|
FakeWorkerClient workerClient = new();
|
||||||
|
GatewaySession session = CreateReadySession(workerClient);
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
EventStreamService service = CreateService(
|
||||||
|
new FakeSessionManager(session),
|
||||||
|
metrics,
|
||||||
|
queueCapacity: 1);
|
||||||
|
workerClient.Events.Add(CreateWorkerEvent(sequence: 1, MxEventFamily.OnDataChange));
|
||||||
|
workerClient.Events.Add(CreateWorkerEvent(sequence: 2, MxEventFamily.OnDataChange));
|
||||||
|
workerClient.Events.Add(CreateWorkerEvent(sequence: 3, MxEventFamily.OnDataChange));
|
||||||
|
workerClient.CompleteAfterConfiguredEvents = true;
|
||||||
|
await using IAsyncEnumerator<MxEvent> subscriber = service
|
||||||
|
.StreamEventsAsync(CreateRequest(session.SessionId), CancellationToken.None)
|
||||||
|
.GetAsyncEnumerator();
|
||||||
|
|
||||||
|
Assert.True(await subscriber.MoveNextAsync().AsTask().WaitAsync(TestTimeout));
|
||||||
|
await WaitUntilAsync(() => session.State == SessionState.Faulted);
|
||||||
|
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
|
||||||
|
async () => await subscriber.MoveNextAsync().AsTask().WaitAsync(TestTimeout));
|
||||||
|
|
||||||
|
Assert.Equal(SessionManagerErrorCode.EventQueueOverflow, exception.ErrorCode);
|
||||||
|
Assert.Equal(SessionState.Faulted, session.State);
|
||||||
|
Assert.Equal(1, metrics.GetSnapshot().QueueOverflows);
|
||||||
|
Assert.Equal(1, metrics.GetSnapshot().Faults);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamEventsAsync_DoesNotSynthesizeOperationComplete()
|
||||||
|
{
|
||||||
|
FakeWorkerClient workerClient = new();
|
||||||
|
GatewaySession session = CreateReadySession(workerClient);
|
||||||
|
EventStreamService service = CreateService(new FakeSessionManager(session));
|
||||||
|
workerClient.Events.Add(CreateWorkerEvent(sequence: 10, MxEventFamily.OnWriteComplete));
|
||||||
|
workerClient.CompleteAfterConfiguredEvents = true;
|
||||||
|
|
||||||
|
List<MxEvent> events = await CollectEventsAsync(service, session.SessionId);
|
||||||
|
|
||||||
|
MxEvent mxEvent = Assert.Single(events);
|
||||||
|
Assert.Equal(MxEventFamily.OnWriteComplete, mxEvent.Family);
|
||||||
|
Assert.DoesNotContain(events, candidate => candidate.Family == MxEventFamily.OperationComplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamEventsAsync_WhenWorkerEventStreamFaults_PropagatesTerminalFault()
|
||||||
|
{
|
||||||
|
FakeWorkerClient workerClient = new()
|
||||||
|
{
|
||||||
|
TerminalException = new WorkerClientException(
|
||||||
|
WorkerClientErrorCode.WorkerFaulted,
|
||||||
|
"worker terminal fault"),
|
||||||
|
};
|
||||||
|
GatewaySession session = CreateReadySession(workerClient);
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
EventStreamService service = CreateService(new FakeSessionManager(session), metrics);
|
||||||
|
await using IAsyncEnumerator<MxEvent> subscriber = service
|
||||||
|
.StreamEventsAsync(CreateRequest(session.SessionId), CancellationToken.None)
|
||||||
|
.GetAsyncEnumerator();
|
||||||
|
|
||||||
|
WorkerClientException exception = await Assert.ThrowsAsync<WorkerClientException>(
|
||||||
|
async () => await subscriber.MoveNextAsync().AsTask().WaitAsync(TestTimeout));
|
||||||
|
|
||||||
|
Assert.Equal(WorkerClientErrorCode.WorkerFaulted, exception.ErrorCode);
|
||||||
|
Assert.Equal(SessionState.Faulted, session.State);
|
||||||
|
Assert.Equal(1, metrics.GetSnapshot().Faults);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EventStreamService CreateService(
|
||||||
|
FakeSessionManager sessionManager,
|
||||||
|
GatewayMetrics? metrics = null,
|
||||||
|
int queueCapacity = 8)
|
||||||
|
{
|
||||||
|
return new EventStreamService(
|
||||||
|
sessionManager,
|
||||||
|
Options.Create(new GatewayOptions
|
||||||
|
{
|
||||||
|
Events = new EventOptions
|
||||||
|
{
|
||||||
|
QueueCapacity = queueCapacity,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new MxAccessGrpcMapper(),
|
||||||
|
metrics ?? new GatewayMetrics(),
|
||||||
|
NullLogger<EventStreamService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<MxEvent>> CollectEventsAsync(
|
||||||
|
EventStreamService service,
|
||||||
|
string sessionId)
|
||||||
|
{
|
||||||
|
List<MxEvent> events = [];
|
||||||
|
await foreach (MxEvent mxEvent in service
|
||||||
|
.StreamEventsAsync(CreateRequest(sessionId), CancellationToken.None)
|
||||||
|
.WithCancellation(CancellationToken.None))
|
||||||
|
{
|
||||||
|
events.Add(mxEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StreamEventsRequest CreateRequest(string sessionId)
|
||||||
|
{
|
||||||
|
return new StreamEventsRequest
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GatewaySession CreateReadySession(FakeWorkerClient workerClient)
|
||||||
|
{
|
||||||
|
GatewaySession session = new(
|
||||||
|
"session-events",
|
||||||
|
GatewayContractInfo.DefaultBackendName,
|
||||||
|
"pipe",
|
||||||
|
"nonce",
|
||||||
|
"client",
|
||||||
|
"client-session",
|
||||||
|
"client-correlation",
|
||||||
|
TimeSpan.FromSeconds(30),
|
||||||
|
TimeSpan.FromSeconds(30),
|
||||||
|
TimeSpan.FromSeconds(10),
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
session.AttachWorkerClient(workerClient);
|
||||||
|
session.MarkReady();
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkerEvent CreateWorkerEvent(
|
||||||
|
ulong sequence,
|
||||||
|
MxEventFamily family)
|
||||||
|
{
|
||||||
|
MxEvent mxEvent = new()
|
||||||
|
{
|
||||||
|
SessionId = "session-events",
|
||||||
|
Family = family,
|
||||||
|
WorkerSequence = sequence,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (family)
|
||||||
|
{
|
||||||
|
case MxEventFamily.OnDataChange:
|
||||||
|
mxEvent.OnDataChange = new OnDataChangeEvent();
|
||||||
|
break;
|
||||||
|
case MxEventFamily.OnWriteComplete:
|
||||||
|
mxEvent.OnWriteComplete = new OnWriteCompleteEvent();
|
||||||
|
break;
|
||||||
|
case MxEventFamily.OperationComplete:
|
||||||
|
mxEvent.OperationComplete = new OperationCompleteEvent();
|
||||||
|
break;
|
||||||
|
case MxEventFamily.OnBufferedDataChange:
|
||||||
|
mxEvent.OnBufferedDataChange = new OnBufferedDataChangeEvent();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WorkerEvent
|
||||||
|
{
|
||||||
|
Event = mxEvent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitUntilAsync(Func<bool> predicate)
|
||||||
|
{
|
||||||
|
using CancellationTokenSource cancellationTokenSource = new(TestTimeout);
|
||||||
|
while (!predicate())
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationTokenSource.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeSessionManager(GatewaySession session) : ISessionManager
|
||||||
|
{
|
||||||
|
public Task<GatewaySession> OpenSessionAsync(
|
||||||
|
SessionOpenRequest request,
|
||||||
|
string? clientIdentity,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetSession(
|
||||||
|
string sessionId,
|
||||||
|
out GatewaySession gatewaySession)
|
||||||
|
{
|
||||||
|
gatewaySession = session;
|
||||||
|
return string.Equals(sessionId, session.SessionId, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
string sessionId,
|
||||||
|
WorkerCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new WorkerCommandReply());
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return session.ReadEventsAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<SessionCloseResult> CloseSessionAsync(
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> CloseExpiredLeasesAsync(
|
||||||
|
DateTimeOffset now,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeWorkerClient : IWorkerClient
|
||||||
|
{
|
||||||
|
public List<WorkerEvent> Events { get; } = [];
|
||||||
|
|
||||||
|
public bool CompleteAfterConfiguredEvents { get; set; }
|
||||||
|
|
||||||
|
public Exception? TerminalException { get; init; }
|
||||||
|
|
||||||
|
public string SessionId { get; } = "session-events";
|
||||||
|
|
||||||
|
public int? ProcessId { get; } = 4321;
|
||||||
|
|
||||||
|
public WorkerClientState State { get; private set; } = WorkerClientState.Ready;
|
||||||
|
|
||||||
|
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
WorkerCommand command,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new WorkerCommandReply());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (WorkerEvent workerEvent in Events)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
yield return workerEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TerminalException is not null)
|
||||||
|
{
|
||||||
|
throw TerminalException;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CompleteAfterConfiguredEvents)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ShutdownAsync(
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
State = WorkerClientState.Closed;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Kill(string reason)
|
||||||
|
{
|
||||||
|
State = WorkerClientState.Faulted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Grpc.Core;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using MxGateway.Contracts;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Grpc;
|
||||||
|
using MxGateway.Server.Security.Authentication;
|
||||||
|
using MxGateway.Server.Security.Authorization;
|
||||||
|
using MxGateway.Server.Sessions;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.Gateway.Grpc;
|
||||||
|
|
||||||
|
public sealed class MxAccessGatewayServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenSession_WithValidRequest_ReturnsSessionDetails()
|
||||||
|
{
|
||||||
|
GatewayRequestIdentityAccessor identityAccessor = new();
|
||||||
|
FakeSessionManager sessionManager = new()
|
||||||
|
{
|
||||||
|
OpenSessionResult = CreateSession("session-1", processId: 4321),
|
||||||
|
};
|
||||||
|
MxAccessGatewayService service = CreateService(sessionManager, identityAccessor);
|
||||||
|
|
||||||
|
using IDisposable identityScope = identityAccessor.Push(CreateIdentity());
|
||||||
|
OpenSessionReply reply = await service.OpenSession(
|
||||||
|
new OpenSessionRequest
|
||||||
|
{
|
||||||
|
ClientSessionName = "operator-session",
|
||||||
|
CommandTimeout = Duration.FromTimeSpan(TimeSpan.FromSeconds(7)),
|
||||||
|
},
|
||||||
|
new TestServerCallContext());
|
||||||
|
|
||||||
|
Assert.Equal("session-1", reply.SessionId);
|
||||||
|
Assert.Equal(GatewayContractInfo.DefaultBackendName, reply.BackendName);
|
||||||
|
Assert.Equal(4321, reply.WorkerProcessId);
|
||||||
|
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, reply.WorkerProtocolVersion);
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||||
|
Assert.Contains("unary-invoke", reply.Capabilities);
|
||||||
|
Assert.Equal("Operator Key", sessionManager.LastClientIdentity);
|
||||||
|
Assert.Equal("operator-session", sessionManager.LastOpenRequest?.ClientSessionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Invoke_WhenSessionMissing_ThrowsNotFound()
|
||||||
|
{
|
||||||
|
FakeSessionManager sessionManager = new()
|
||||||
|
{
|
||||||
|
InvokeException = new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.SessionNotFound,
|
||||||
|
"Session session-missing was not found."),
|
||||||
|
};
|
||||||
|
MxAccessGatewayService service = CreateService(sessionManager);
|
||||||
|
|
||||||
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
|
async () => await service.Invoke(
|
||||||
|
CreatePingRequest("session-missing"),
|
||||||
|
new TestServerCallContext()));
|
||||||
|
|
||||||
|
Assert.Equal(StatusCode.NotFound, exception.StatusCode);
|
||||||
|
Assert.Contains("session-missing", exception.Status.Detail, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Invoke_WithMismatchedPayload_ThrowsInvalidArgumentAndDoesNotCallSessionManager()
|
||||||
|
{
|
||||||
|
FakeSessionManager sessionManager = new();
|
||||||
|
MxAccessGatewayService service = CreateService(sessionManager);
|
||||||
|
MxCommandRequest request = new()
|
||||||
|
{
|
||||||
|
SessionId = "session-1",
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItem,
|
||||||
|
Ping = new PingCommand { Message = "wrong-payload" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
|
async () => await service.Invoke(request, new TestServerCallContext()));
|
||||||
|
|
||||||
|
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||||
|
Assert.Equal(0, sessionManager.InvokeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Invoke_WithWorkerReply_ReturnsHresultStatusAndMethodPayload()
|
||||||
|
{
|
||||||
|
const int hresult = unchecked((int)0x80004005);
|
||||||
|
FakeSessionManager sessionManager = new()
|
||||||
|
{
|
||||||
|
InvokeReply = new WorkerCommandReply
|
||||||
|
{
|
||||||
|
Reply = new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-1",
|
||||||
|
CorrelationId = "worker-correlation",
|
||||||
|
Kind = MxCommandKind.AddItem,
|
||||||
|
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||||
|
Hresult = hresult,
|
||||||
|
AddItem = new AddItemReply { ItemHandle = 42 },
|
||||||
|
DiagnosticMessage = "mxaccess diagnostic",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
sessionManager.InvokeReply.Reply.Statuses.Add(new MxStatusProxy
|
||||||
|
{
|
||||||
|
Success = 0,
|
||||||
|
Category = MxStatusCategory.SoftwareError,
|
||||||
|
Detail = 1001,
|
||||||
|
DiagnosticText = "status detail",
|
||||||
|
});
|
||||||
|
MxAccessGatewayService service = CreateService(sessionManager);
|
||||||
|
MxCommandRequest request = new()
|
||||||
|
{
|
||||||
|
SessionId = "session-1",
|
||||||
|
ClientCorrelationId = "client-correlation",
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItem,
|
||||||
|
AddItem = new AddItemCommand
|
||||||
|
{
|
||||||
|
ServerHandle = 12,
|
||||||
|
ItemDefinition = "Galaxy.Tag.Value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
MxCommandReply reply = await service.Invoke(request, new TestServerCallContext());
|
||||||
|
|
||||||
|
Assert.Equal(MxCommandKind.AddItem, sessionManager.LastWorkerCommand?.Command.Kind);
|
||||||
|
Assert.Equal("Galaxy.Tag.Value", sessionManager.LastWorkerCommand?.Command.AddItem.ItemDefinition);
|
||||||
|
Assert.NotNull(sessionManager.LastWorkerCommand?.EnqueueTimestamp);
|
||||||
|
Assert.Equal(hresult, reply.Hresult);
|
||||||
|
Assert.Equal(42, reply.AddItem.ItemHandle);
|
||||||
|
Assert.Equal("status detail", Assert.Single(reply.Statuses).DiagnosticText);
|
||||||
|
Assert.Equal("mxaccess diagnostic", reply.DiagnosticMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamEvents_WithAfterSequence_WritesOnlyLaterEvents()
|
||||||
|
{
|
||||||
|
FakeSessionManager sessionManager = new();
|
||||||
|
sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 1));
|
||||||
|
sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 2));
|
||||||
|
MxAccessGatewayService service = CreateService(sessionManager);
|
||||||
|
TestServerStreamWriter<MxEvent> writer = new();
|
||||||
|
|
||||||
|
await service.StreamEvents(
|
||||||
|
new StreamEventsRequest
|
||||||
|
{
|
||||||
|
SessionId = "session-1",
|
||||||
|
AfterWorkerSequence = 1,
|
||||||
|
},
|
||||||
|
writer,
|
||||||
|
new TestServerCallContext());
|
||||||
|
|
||||||
|
MxEvent writtenEvent = Assert.Single(writer.Messages);
|
||||||
|
Assert.Equal((ulong)2, writtenEvent.WorkerSequence);
|
||||||
|
Assert.Equal("session-1", sessionManager.LastReadEventsSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CloseSession_WithBlankSessionId_ThrowsInvalidArgument()
|
||||||
|
{
|
||||||
|
MxAccessGatewayService service = CreateService(new FakeSessionManager());
|
||||||
|
|
||||||
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
|
async () => await service.CloseSession(
|
||||||
|
new CloseSessionRequest(),
|
||||||
|
new TestServerCallContext()));
|
||||||
|
|
||||||
|
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxAccessGatewayService CreateService(
|
||||||
|
FakeSessionManager sessionManager,
|
||||||
|
IGatewayRequestIdentityAccessor? identityAccessor = null)
|
||||||
|
{
|
||||||
|
return new MxAccessGatewayService(
|
||||||
|
sessionManager,
|
||||||
|
identityAccessor ?? new GatewayRequestIdentityAccessor(),
|
||||||
|
new MxAccessGrpcRequestValidator(),
|
||||||
|
new MxAccessGrpcMapper(),
|
||||||
|
new FakeEventStreamService(sessionManager),
|
||||||
|
NullLogger<MxAccessGatewayService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApiKeyIdentity CreateIdentity()
|
||||||
|
{
|
||||||
|
return new ApiKeyIdentity(
|
||||||
|
KeyId: "operator01",
|
||||||
|
KeyPrefix: "mxgw_operator01",
|
||||||
|
DisplayName: "Operator Key",
|
||||||
|
Scopes: new HashSet<string>(StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GatewaySession CreateSession(
|
||||||
|
string sessionId,
|
||||||
|
int processId)
|
||||||
|
{
|
||||||
|
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(processId));
|
||||||
|
session.MarkReady();
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommandRequest CreatePingRequest(string sessionId)
|
||||||
|
{
|
||||||
|
return new MxCommandRequest
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Ping,
|
||||||
|
Ping = new PingCommand { Message = "ping" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkerEvent CreateWorkerEvent(
|
||||||
|
string sessionId,
|
||||||
|
ulong workerSequence)
|
||||||
|
{
|
||||||
|
return new WorkerEvent
|
||||||
|
{
|
||||||
|
Event = new MxEvent
|
||||||
|
{
|
||||||
|
Family = MxEventFamily.OnDataChange,
|
||||||
|
SessionId = sessionId,
|
||||||
|
WorkerSequence = workerSequence,
|
||||||
|
OnDataChange = new OnDataChangeEvent(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeSessionManager : ISessionManager
|
||||||
|
{
|
||||||
|
public GatewaySession? OpenSessionResult { get; init; }
|
||||||
|
|
||||||
|
public SessionOpenRequest? LastOpenRequest { get; private set; }
|
||||||
|
|
||||||
|
public string? LastClientIdentity { get; private set; }
|
||||||
|
|
||||||
|
public string? LastReadEventsSessionId { get; private set; }
|
||||||
|
|
||||||
|
public WorkerCommand? LastWorkerCommand { get; private set; }
|
||||||
|
|
||||||
|
public WorkerCommandReply InvokeReply { get; init; } = new()
|
||||||
|
{
|
||||||
|
Reply = new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-1",
|
||||||
|
Kind = MxCommandKind.Ping,
|
||||||
|
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public Exception? InvokeException { get; init; }
|
||||||
|
|
||||||
|
public int InvokeCount { get; private set; }
|
||||||
|
|
||||||
|
public List<WorkerEvent> Events { get; } = [];
|
||||||
|
|
||||||
|
public void RecordReadEventsSessionId(string sessionId)
|
||||||
|
{
|
||||||
|
LastReadEventsSessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<GatewaySession> OpenSessionAsync(
|
||||||
|
SessionOpenRequest request,
|
||||||
|
string? clientIdentity,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastOpenRequest = request;
|
||||||
|
LastClientIdentity = clientIdentity;
|
||||||
|
|
||||||
|
return Task.FromResult(OpenSessionResult ?? CreateSession("session-1", processId: 1234));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetSession(
|
||||||
|
string sessionId,
|
||||||
|
out GatewaySession session)
|
||||||
|
{
|
||||||
|
session = OpenSessionResult ?? CreateSession(sessionId, processId: 1234);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
string sessionId,
|
||||||
|
WorkerCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
InvokeCount++;
|
||||||
|
LastWorkerCommand = command;
|
||||||
|
|
||||||
|
if (InvokeException is not null)
|
||||||
|
{
|
||||||
|
throw InvokeException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(InvokeReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
string sessionId,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastReadEventsSessionId = sessionId;
|
||||||
|
foreach (WorkerEvent workerEvent in Events)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
await Task.Yield();
|
||||||
|
yield return workerEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<SessionCloseResult> CloseSessionAsync(
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> CloseExpiredLeasesAsync(
|
||||||
|
DateTimeOffset now,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeEventStreamService(FakeSessionManager sessionManager) : IEventStreamService
|
||||||
|
{
|
||||||
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
sessionManager.RecordReadEventsSessionId(request.SessionId);
|
||||||
|
foreach (WorkerEvent workerEvent in sessionManager.Events)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
await Task.Yield();
|
||||||
|
if (workerEvent.Event.WorkerSequence <= request.AfterWorkerSequence)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return workerEvent.Event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeWorkerClient(int processId) : IWorkerClient
|
||||||
|
{
|
||||||
|
public string SessionId { get; } = "session-1";
|
||||||
|
|
||||||
|
public int? ProcessId { get; } = processId;
|
||||||
|
|
||||||
|
public WorkerClientState State { get; } = WorkerClientState.Ready;
|
||||||
|
|
||||||
|
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
WorkerCommand command,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new WorkerCommandReply());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ShutdownAsync(
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Kill(string reason)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestServerStreamWriter<T> : IServerStreamWriter<T>
|
||||||
|
{
|
||||||
|
public List<T> Messages { get; } = [];
|
||||||
|
|
||||||
|
public WriteOptions? WriteOptions { get; set; }
|
||||||
|
|
||||||
|
public Task WriteAsync(T message)
|
||||||
|
{
|
||||||
|
Messages.Add(message);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext
|
||||||
|
{
|
||||||
|
private readonly Metadata requestHeaders = [];
|
||||||
|
private readonly Metadata responseTrailers = [];
|
||||||
|
private readonly Dictionary<object, object> userState = [];
|
||||||
|
private Status status;
|
||||||
|
private WriteOptions? writeOptions;
|
||||||
|
|
||||||
|
protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test";
|
||||||
|
|
||||||
|
protected override string HostCore => "localhost";
|
||||||
|
|
||||||
|
protected override string PeerCore => "ipv4:127.0.0.1:5000";
|
||||||
|
|
||||||
|
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
|
||||||
|
|
||||||
|
protected override Metadata RequestHeadersCore => requestHeaders;
|
||||||
|
|
||||||
|
protected override CancellationToken CancellationTokenCore => cancellationToken;
|
||||||
|
|
||||||
|
protected override Metadata ResponseTrailersCore => responseTrailers;
|
||||||
|
|
||||||
|
protected override Status StatusCore
|
||||||
|
{
|
||||||
|
get => status;
|
||||||
|
set => status = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override WriteOptions? WriteOptionsCore
|
||||||
|
{
|
||||||
|
get => writeOptions;
|
||||||
|
set => writeOptions = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override AuthContext AuthContextCore { get; } = new(
|
||||||
|
string.Empty,
|
||||||
|
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
|
||||||
|
|
||||||
|
protected override IDictionary<object, object> UserStateCore => userState;
|
||||||
|
|
||||||
|
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override ContextPropagationToken CreatePropagationTokenCore(
|
||||||
|
ContextPropagationOptions? options)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Grpc;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.Gateway.Grpc;
|
||||||
|
|
||||||
|
public sealed class MxAccessGrpcMapperTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void MapCommand_ClonesMethodSpecificPayloadForWorkerBoundary()
|
||||||
|
{
|
||||||
|
MxAccessGrpcMapper mapper = new();
|
||||||
|
MxCommandRequest request = new()
|
||||||
|
{
|
||||||
|
SessionId = "session-1",
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Write,
|
||||||
|
Write = new WriteCommand
|
||||||
|
{
|
||||||
|
ServerHandle = 10,
|
||||||
|
ItemHandle = 20,
|
||||||
|
UserId = 30,
|
||||||
|
Value = new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.String,
|
||||||
|
StringValue = "value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
WorkerCommand workerCommand = mapper.MapCommand(request);
|
||||||
|
request.Command.Write.Value.StringValue = "changed";
|
||||||
|
|
||||||
|
Assert.Equal(MxCommandKind.Write, workerCommand.Command.Kind);
|
||||||
|
Assert.Equal("value", workerCommand.Command.Write.Value.StringValue);
|
||||||
|
Assert.NotNull(workerCommand.EnqueueTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapCommandReply_PreservesHresultStatusesAndPayload()
|
||||||
|
{
|
||||||
|
const int hresult = unchecked((int)0x80070005);
|
||||||
|
WorkerCommandReply workerReply = new()
|
||||||
|
{
|
||||||
|
Reply = new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-1",
|
||||||
|
Kind = MxCommandKind.Register,
|
||||||
|
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||||
|
Hresult = hresult,
|
||||||
|
Register = new RegisterReply { ServerHandle = 50 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
workerReply.Reply.Statuses.Add(new MxStatusProxy
|
||||||
|
{
|
||||||
|
Success = 0,
|
||||||
|
Category = MxStatusCategory.SecurityError,
|
||||||
|
DiagnosticText = "denied",
|
||||||
|
});
|
||||||
|
|
||||||
|
MxCommandReply publicReply = new MxAccessGrpcMapper().MapCommandReply(workerReply);
|
||||||
|
|
||||||
|
Assert.Equal(hresult, publicReply.Hresult);
|
||||||
|
Assert.Equal(50, publicReply.Register.ServerHandle);
|
||||||
|
Assert.Equal("denied", Assert.Single(publicReply.Statuses).DiagnosticText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapCommandReply_WhenWorkerReplyMissing_ReturnsProtocolViolationReply()
|
||||||
|
{
|
||||||
|
MxCommandReply publicReply = new MxAccessGrpcMapper().MapCommandReply(new WorkerCommandReply());
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.ProtocolViolation, publicReply.ProtocolStatus.Code);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
public sealed class SessionManagerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenSessionAsync_WithWorkerReady_RegistersReadySession()
|
||||||
|
{
|
||||||
|
FakeWorkerClient workerClient = new();
|
||||||
|
FakeSessionWorkerClientFactory factory = new(workerClient)
|
||||||
|
{
|
||||||
|
ApplyLifecycleTransitions = true,
|
||||||
|
};
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
SessionManager manager = CreateManager(factory, metrics: metrics);
|
||||||
|
|
||||||
|
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(manager.TryGetSession(session.SessionId, out GatewaySession registered));
|
||||||
|
Assert.Same(session, registered);
|
||||||
|
Assert.Equal(SessionState.Ready, session.State);
|
||||||
|
Assert.Equal("client-1", session.ClientIdentity);
|
||||||
|
Assert.Equal(["StartingWorker", "WaitingForPipe", "Handshaking", "InitializingWorker"], factory.ObservedStates);
|
||||||
|
Assert.Equal(1, metrics.GetSnapshot().OpenSessions);
|
||||||
|
Assert.Equal(1, metrics.GetSnapshot().SessionsOpened);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WhenSessionReady_ForwardsCommandToWorker()
|
||||||
|
{
|
||||||
|
FakeWorkerClient workerClient = new();
|
||||||
|
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient));
|
||||||
|
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
|
||||||
|
|
||||||
|
WorkerCommandReply reply = await manager.InvokeAsync(
|
||||||
|
session.SessionId,
|
||||||
|
CreateCommand(MxCommandKind.Ping),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, workerClient.InvokeCount);
|
||||||
|
Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_WhenSessionFaulted_RejectsCommand()
|
||||||
|
{
|
||||||
|
FakeWorkerClient workerClient = new();
|
||||||
|
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient));
|
||||||
|
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
|
||||||
|
session.MarkFaulted("test fault");
|
||||||
|
|
||||||
|
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
|
||||||
|
async () => await manager.InvokeAsync(
|
||||||
|
session.SessionId,
|
||||||
|
CreateCommand(MxCommandKind.Ping),
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Equal(SessionManagerErrorCode.SessionNotReady, exception.ErrorCode);
|
||||||
|
Assert.Equal(0, workerClient.InvokeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CloseSessionAsync_WhenCalledTwice_IsIdempotent()
|
||||||
|
{
|
||||||
|
FakeWorkerClient workerClient = new();
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient), metrics: metrics);
|
||||||
|
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
|
||||||
|
|
||||||
|
SessionCloseResult firstClose = await manager.CloseSessionAsync(session.SessionId, CancellationToken.None);
|
||||||
|
SessionCloseResult secondClose = await manager.CloseSessionAsync(session.SessionId, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(firstClose.AlreadyClosed);
|
||||||
|
Assert.True(secondClose.AlreadyClosed);
|
||||||
|
Assert.Equal(SessionState.Closed, firstClose.FinalState);
|
||||||
|
Assert.Equal(SessionState.Closed, secondClose.FinalState);
|
||||||
|
Assert.Equal(1, workerClient.ShutdownCount);
|
||||||
|
Assert.Equal(1, metrics.GetSnapshot().SessionsClosed);
|
||||||
|
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenSessionAsync_WhenWorkerCreationFails_RemovesSessionFromRegistry()
|
||||||
|
{
|
||||||
|
SessionRegistry registry = new();
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
SessionManager manager = CreateManager(
|
||||||
|
new FailingSessionWorkerClientFactory(),
|
||||||
|
registry,
|
||||||
|
metrics);
|
||||||
|
|
||||||
|
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
|
||||||
|
async () => await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Equal(SessionManagerErrorCode.OpenFailed, exception.ErrorCode);
|
||||||
|
Assert.Equal(0, registry.Count);
|
||||||
|
Assert.Equal(0, metrics.GetSnapshot().SessionsOpened);
|
||||||
|
Assert.Equal(1, metrics.GetSnapshot().Faults);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CloseExpiredLeasesAsync_ClosesExpiredSessionsOnly()
|
||||||
|
{
|
||||||
|
FakeWorkerClient expiredClient = new();
|
||||||
|
FakeWorkerClient activeClient = new();
|
||||||
|
QueueingSessionWorkerClientFactory factory = new(expiredClient, activeClient);
|
||||||
|
SessionManager manager = CreateManager(factory);
|
||||||
|
GatewaySession expiredSession = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
|
||||||
|
GatewaySession activeSession = await manager.OpenSessionAsync(CreateOpenRequest(), "client-2", CancellationToken.None);
|
||||||
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
|
expiredSession.ExtendLease(now.AddSeconds(-1));
|
||||||
|
activeSession.ExtendLease(now.AddMinutes(5));
|
||||||
|
|
||||||
|
int closedCount = await manager.CloseExpiredLeasesAsync(now, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, closedCount);
|
||||||
|
Assert.Equal(SessionState.Closed, expiredSession.State);
|
||||||
|
Assert.Equal(SessionState.Ready, activeSession.State);
|
||||||
|
Assert.Equal(1, expiredClient.ShutdownCount);
|
||||||
|
Assert.Equal(0, activeClient.ShutdownCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ShutdownAsync_ClosesAllRegisteredSessions()
|
||||||
|
{
|
||||||
|
FakeWorkerClient firstClient = new();
|
||||||
|
FakeWorkerClient secondClient = new();
|
||||||
|
QueueingSessionWorkerClientFactory factory = new(firstClient, secondClient);
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
SessionManager manager = CreateManager(factory, metrics: metrics);
|
||||||
|
GatewaySession firstSession = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
|
||||||
|
GatewaySession secondSession = await manager.OpenSessionAsync(CreateOpenRequest(), "client-2", CancellationToken.None);
|
||||||
|
|
||||||
|
await manager.ShutdownAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(SessionState.Closed, firstSession.State);
|
||||||
|
Assert.Equal(SessionState.Closed, secondSession.State);
|
||||||
|
Assert.Equal(1, firstClient.ShutdownCount);
|
||||||
|
Assert.Equal(1, secondClient.ShutdownCount);
|
||||||
|
Assert.Equal(2, metrics.GetSnapshot().SessionsClosed);
|
||||||
|
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SessionManager CreateManager(
|
||||||
|
ISessionWorkerClientFactory factory,
|
||||||
|
ISessionRegistry? registry = null,
|
||||||
|
GatewayMetrics? metrics = null,
|
||||||
|
GatewayOptions? options = null)
|
||||||
|
{
|
||||||
|
return new SessionManager(
|
||||||
|
registry ?? new SessionRegistry(),
|
||||||
|
factory,
|
||||||
|
Options.Create(options ?? CreateOptions()),
|
||||||
|
metrics ?? new GatewayMetrics());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GatewayOptions CreateOptions()
|
||||||
|
{
|
||||||
|
return new GatewayOptions
|
||||||
|
{
|
||||||
|
Sessions = new SessionOptions
|
||||||
|
{
|
||||||
|
DefaultCommandTimeoutSeconds = 30,
|
||||||
|
MaxSessions = 64,
|
||||||
|
},
|
||||||
|
Worker = new WorkerOptions
|
||||||
|
{
|
||||||
|
StartupTimeoutSeconds = 30,
|
||||||
|
ShutdownTimeoutSeconds = 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SessionOpenRequest CreateOpenRequest()
|
||||||
|
{
|
||||||
|
return new SessionOpenRequest(
|
||||||
|
RequestedBackend: null,
|
||||||
|
ClientSessionName: "test-session",
|
||||||
|
ClientCorrelationId: "client-correlation-1",
|
||||||
|
CommandTimeout: Duration.FromTimeSpan(TimeSpan.FromSeconds(5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkerCommand CreateCommand(MxCommandKind kind)
|
||||||
|
{
|
||||||
|
return new WorkerCommand
|
||||||
|
{
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = kind,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeSessionWorkerClientFactory(IWorkerClient workerClient) : ISessionWorkerClientFactory
|
||||||
|
{
|
||||||
|
public List<string> ObservedStates { get; } = [];
|
||||||
|
|
||||||
|
public bool ApplyLifecycleTransitions { get; init; }
|
||||||
|
|
||||||
|
public Task<IWorkerClient> CreateAsync(
|
||||||
|
GatewaySession session,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ObservedStates.Add(session.State.ToString());
|
||||||
|
if (ApplyLifecycleTransitions)
|
||||||
|
{
|
||||||
|
session.TransitionTo(SessionState.WaitingForPipe);
|
||||||
|
ObservedStates.Add(session.State.ToString());
|
||||||
|
session.TransitionTo(SessionState.Handshaking);
|
||||||
|
ObservedStates.Add(session.State.ToString());
|
||||||
|
session.TransitionTo(SessionState.InitializingWorker);
|
||||||
|
ObservedStates.Add(session.State.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(workerClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class QueueingSessionWorkerClientFactory : ISessionWorkerClientFactory
|
||||||
|
{
|
||||||
|
private readonly Queue<IWorkerClient> _workerClients;
|
||||||
|
|
||||||
|
public QueueingSessionWorkerClientFactory(params IWorkerClient[] workerClients)
|
||||||
|
{
|
||||||
|
_workerClients = new Queue<IWorkerClient>(workerClients);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IWorkerClient> CreateAsync(
|
||||||
|
GatewaySession session,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(_workerClients.Dequeue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FailingSessionWorkerClientFactory : ISessionWorkerClientFactory
|
||||||
|
{
|
||||||
|
public Task<IWorkerClient> CreateAsync(
|
||||||
|
GatewaySession session,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("worker startup failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeWorkerClient : IWorkerClient
|
||||||
|
{
|
||||||
|
public string SessionId { get; init; } = "session-1";
|
||||||
|
|
||||||
|
public int? ProcessId { get; init; } = 1234;
|
||||||
|
|
||||||
|
public WorkerClientState State { get; set; } = WorkerClientState.Ready;
|
||||||
|
|
||||||
|
public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public int InvokeCount { get; private set; }
|
||||||
|
|
||||||
|
public int ShutdownCount { get; private set; }
|
||||||
|
|
||||||
|
public int KillCount { get; private set; }
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
WorkerCommand command,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
InvokeCount++;
|
||||||
|
MxCommandKind kind = command.Command?.Kind ?? MxCommandKind.Unspecified;
|
||||||
|
|
||||||
|
return Task.FromResult(new WorkerCommandReply
|
||||||
|
{
|
||||||
|
Reply = new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = SessionId,
|
||||||
|
CorrelationId = "correlation-1",
|
||||||
|
Kind = kind,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ShutdownAsync(
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ShutdownCount++;
|
||||||
|
State = WorkerClientState.Closed;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Kill(string reason)
|
||||||
|
{
|
||||||
|
KillCount++;
|
||||||
|
State = WorkerClientState.Faulted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,6 +109,32 @@ public sealed class WorkerClientTests
|
|||||||
Assert.Equal(MxEventFamily.OperationComplete, events.Current.Event.Family);
|
Assert.Equal(MxEventFamily.OperationComplete, events.Current.Event.Family);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadLoop_WhenEventQueueOverflows_FaultsClient()
|
||||||
|
{
|
||||||
|
await using PipePair pipePair = await PipePair.CreateAsync();
|
||||||
|
await using WorkerClient client = CreateClient(
|
||||||
|
pipePair,
|
||||||
|
new WorkerClientOptions
|
||||||
|
{
|
||||||
|
EventChannelCapacity = 1,
|
||||||
|
HeartbeatGrace = TimeSpan.FromSeconds(30),
|
||||||
|
HeartbeatCheckInterval = TimeSpan.FromSeconds(30),
|
||||||
|
});
|
||||||
|
await CompleteHandshakeAsync(client, pipePair);
|
||||||
|
|
||||||
|
await pipePair.WorkerWriter.WriteAsync(
|
||||||
|
CreateEventEnvelope(sequence: 11, MxEventFamily.OnDataChange));
|
||||||
|
await pipePair.WorkerWriter.WriteAsync(
|
||||||
|
CreateEventEnvelope(sequence: 12, MxEventFamily.OnDataChange));
|
||||||
|
|
||||||
|
await WaitUntilAsync(
|
||||||
|
() => client.State == WorkerClientState.Faulted,
|
||||||
|
TestTimeout);
|
||||||
|
|
||||||
|
Assert.Equal(WorkerClientState.Faulted, client.State);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReadLoop_WhenPipeDisconnects_FaultsClient()
|
public async Task ReadLoop_WhenPipeDisconnects_FaultsClient()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Worker.Conversion;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.Tests.Conversion;
|
||||||
|
|
||||||
|
public sealed class HResultConverterTests
|
||||||
|
{
|
||||||
|
private readonly HResultConverter _converter = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Convert_WithComException_CapturesExceptionHResult()
|
||||||
|
{
|
||||||
|
COMException exception = new("Sensitive provider text should not be copied.", unchecked((int)0x80070057));
|
||||||
|
|
||||||
|
HResultConversion converted = _converter.Convert(exception);
|
||||||
|
|
||||||
|
Assert.Equal(unchecked((int)0x80070057), converted.HResult);
|
||||||
|
Assert.Equal(ProtocolStatusCode.MxaccessFailure, converted.ProtocolStatus.Code);
|
||||||
|
Assert.Contains("0x80070057", converted.ProtocolStatus.Message);
|
||||||
|
Assert.Contains(typeof(COMException).FullName!, converted.DiagnosticMessage);
|
||||||
|
Assert.DoesNotContain("Sensitive provider text", converted.DiagnosticMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateProtocolStatus_WithSuccessHResult_ReturnsOk()
|
||||||
|
{
|
||||||
|
ProtocolStatus status = _converter.CreateProtocolStatus(0);
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, status.Code);
|
||||||
|
Assert.Equal("HRESULT 0x00000000", status.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Convert_WithNonComException_CapturesExceptionHResult()
|
||||||
|
{
|
||||||
|
InvalidOperationException exception = new("do not include this");
|
||||||
|
|
||||||
|
HResultConversion converted = _converter.Convert(exception);
|
||||||
|
|
||||||
|
Assert.Equal(exception.HResult, converted.HResult);
|
||||||
|
Assert.Equal(ProtocolStatusCode.MxaccessFailure, converted.ProtocolStatus.Code);
|
||||||
|
Assert.Contains("0x", converted.DiagnosticMessage);
|
||||||
|
Assert.DoesNotContain("do not include this", converted.DiagnosticMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Worker.Conversion;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.Tests.Conversion;
|
||||||
|
|
||||||
|
public sealed class MxStatusProxyConverterTests
|
||||||
|
{
|
||||||
|
private readonly MxStatusProxyConverter _converter = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Convert_WithStatusStruct_PreservesStatusFields()
|
||||||
|
{
|
||||||
|
FakeMxStatusProxy status = new()
|
||||||
|
{
|
||||||
|
success = 1,
|
||||||
|
category = 5,
|
||||||
|
detectedBy = 3,
|
||||||
|
detail = 21,
|
||||||
|
};
|
||||||
|
|
||||||
|
MxStatusProxy converted = _converter.Convert(status);
|
||||||
|
|
||||||
|
Assert.Equal(1, converted.Success);
|
||||||
|
Assert.Equal(MxStatusCategory.OperationalError, converted.Category);
|
||||||
|
Assert.Equal(MxStatusSource.RespondingNmx, converted.DetectedBy);
|
||||||
|
Assert.Equal(21, converted.Detail);
|
||||||
|
Assert.Equal(5, converted.RawCategory);
|
||||||
|
Assert.Equal(3, converted.RawDetectedBy);
|
||||||
|
Assert.Equal("Invalid reference", converted.DiagnosticText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConvertMany_WithStatusArray_DoesNotCollapseEntries()
|
||||||
|
{
|
||||||
|
FakeMxStatusProxy[] statuses =
|
||||||
|
[
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
success = 1,
|
||||||
|
category = 0,
|
||||||
|
detectedBy = 0,
|
||||||
|
detail = 0,
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
success = 0,
|
||||||
|
category = 6,
|
||||||
|
detectedBy = 5,
|
||||||
|
detail = 33,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
IReadOnlyList<MxStatusProxy> converted = _converter.ConvertMany(statuses);
|
||||||
|
|
||||||
|
Assert.Equal(2, converted.Count);
|
||||||
|
Assert.Equal(MxStatusCategory.Ok, converted[0].Category);
|
||||||
|
Assert.Equal(MxStatusCategory.SecurityError, converted[1].Category);
|
||||||
|
Assert.Equal(MxStatusSource.RespondingAutomationObject, converted[1].DetectedBy);
|
||||||
|
Assert.Equal("Write access denied", converted[1].DiagnosticText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Convert_WithUnknownCategoryAndSource_PreservesRawFields()
|
||||||
|
{
|
||||||
|
FakeMxStatusProxy status = new()
|
||||||
|
{
|
||||||
|
success = -1,
|
||||||
|
category = 99,
|
||||||
|
detectedBy = 42,
|
||||||
|
detail = 1234,
|
||||||
|
};
|
||||||
|
|
||||||
|
MxStatusProxy converted = _converter.Convert(status);
|
||||||
|
|
||||||
|
Assert.Equal(-1, converted.Success);
|
||||||
|
Assert.Equal(MxStatusCategory.Unknown, converted.Category);
|
||||||
|
Assert.Equal(MxStatusSource.Unknown, converted.DetectedBy);
|
||||||
|
Assert.Equal(99, converted.RawCategory);
|
||||||
|
Assert.Equal(42, converted.RawDetectedBy);
|
||||||
|
Assert.Equal(1234, converted.Detail);
|
||||||
|
Assert.Equal(string.Empty, converted.DiagnosticText);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PreserveCompletionOnlyStatusBytes_ReturnsRawHexMetadata()
|
||||||
|
{
|
||||||
|
string rawStatus = _converter.PreserveCompletionOnlyStatusBytes(
|
||||||
|
[0x00, 0x00, 0x50, 0x80, 0x00]);
|
||||||
|
|
||||||
|
Assert.Equal("completion_only_status_hex=0000508000", rawStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Convert_WithMissingStatusField_ThrowsConversionException()
|
||||||
|
{
|
||||||
|
MxStatusConversionException exception =
|
||||||
|
Assert.Throws<MxStatusConversionException>(() => _converter.Convert(new MissingFields()));
|
||||||
|
|
||||||
|
Assert.Contains("success", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct FakeMxStatusProxy
|
||||||
|
{
|
||||||
|
public short success;
|
||||||
|
|
||||||
|
public int category;
|
||||||
|
|
||||||
|
public int detectedBy;
|
||||||
|
|
||||||
|
public short detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MissingFields
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MxGateway.Contracts;
|
using MxGateway.Contracts;
|
||||||
@@ -37,6 +38,10 @@ public sealed class WorkerPipeSessionTests
|
|||||||
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerHello, written[0].BodyCase);
|
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerHello, written[0].BodyCase);
|
||||||
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerReady, written[1].BodyCase);
|
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerReady, written[1].BodyCase);
|
||||||
Assert.Equal(Nonce, written[0].WorkerHello.Nonce);
|
Assert.Equal(Nonce, written[0].WorkerHello.Nonce);
|
||||||
|
Assert.Equal(1234, written[1].WorkerReady.WorkerProcessId);
|
||||||
|
Assert.Equal(MxGateway.Worker.MxAccess.MxAccessInteropInfo.ProgId, written[1].WorkerReady.MxaccessProgid);
|
||||||
|
Assert.Equal(MxGateway.Worker.MxAccess.MxAccessInteropInfo.Clsid, written[1].WorkerReady.MxaccessClsid);
|
||||||
|
Assert.NotNull(written[1].WorkerReady.ReadyTimestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -117,6 +122,31 @@ public sealed class WorkerPipeSessionTests
|
|||||||
Assert.Equal(WorkerFaultCategory.ProtocolViolation, fault.WorkerFault.Category);
|
Assert.Equal(WorkerFaultCategory.ProtocolViolation, fault.WorkerFault.Category);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CompleteStartupHandshakeAsync_WhenMxAccessCreationFails_WritesFaultInsteadOfReady()
|
||||||
|
{
|
||||||
|
const int hresult = unchecked((int)0x80040154);
|
||||||
|
WorkerFrameProtocolOptions options = CreateOptions();
|
||||||
|
MemoryStream inbound = new();
|
||||||
|
await new WorkerFrameWriter(inbound, options).WriteAsync(CreateGatewayHelloEnvelope());
|
||||||
|
inbound.Position = 0;
|
||||||
|
MemoryStream outbound = new();
|
||||||
|
WorkerPipeSession session = CreateSession(inbound, outbound, options);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<COMException>(
|
||||||
|
async () => await session.CompleteStartupHandshakeAsync(
|
||||||
|
_ => Task.FromException<WorkerReady>(new COMException("Class not registered.", hresult))));
|
||||||
|
|
||||||
|
WorkerEnvelope[] written = ReadWrittenFrames(outbound, options);
|
||||||
|
Assert.Equal(2, written.Length);
|
||||||
|
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerHello, written[0].BodyCase);
|
||||||
|
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerFault, written[1].BodyCase);
|
||||||
|
Assert.Equal(WorkerFaultCategory.MxaccessCreationFailed, written[1].WorkerFault.Category);
|
||||||
|
Assert.Equal(hresult, written[1].WorkerFault.Hresult);
|
||||||
|
Assert.Equal(typeof(COMException).FullName, written[1].WorkerFault.ExceptionType);
|
||||||
|
Assert.Equal(ProtocolStatusCode.WorkerUnavailable, written[1].WorkerFault.ProtocolStatus.Code);
|
||||||
|
}
|
||||||
|
|
||||||
private static WorkerPipeSession CreateSession(
|
private static WorkerPipeSession CreateSession(
|
||||||
Stream inbound,
|
Stream inbound,
|
||||||
Stream outbound,
|
Stream outbound,
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Worker.MxAccess;
|
||||||
|
using MxGateway.Worker.Sta;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.Tests.MxAccess;
|
||||||
|
|
||||||
|
public sealed class MxAccessCommandExecutorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_Register_CallsMxAccessOnStaAndPreservesServerHandle()
|
||||||
|
{
|
||||||
|
FakeMxAccessComObjectFactory factory = new(new FakeMxAccessComObject(registerHandle: 42));
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
||||||
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
|
|
||||||
|
MxCommandReply reply = await session.DispatchAsync(CreateRegisterCommand("correlation-1", "client-a"));
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||||
|
Assert.True(reply.HasHresult);
|
||||||
|
Assert.Equal(0, reply.Hresult);
|
||||||
|
Assert.Equal(42, reply.Register.ServerHandle);
|
||||||
|
Assert.Equal(MxDataType.Integer, reply.ReturnValue.DataType);
|
||||||
|
Assert.Equal(42, reply.ReturnValue.Int32Value);
|
||||||
|
Assert.Equal(runtime.StaThreadId, factory.FakeComObject.RegisterThreadId);
|
||||||
|
Assert.Equal("client-a", factory.FakeComObject.RegisteredClientName);
|
||||||
|
|
||||||
|
RegisteredServerHandle registeredServerHandle = Assert.Single(
|
||||||
|
await session.GetRegisteredServerHandlesAsync());
|
||||||
|
Assert.Equal(42, registeredServerHandle.ServerHandle);
|
||||||
|
Assert.Equal("client-a", registeredServerHandle.ClientName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_Unregister_CallsMxAccessOnStaAndRemovesTrackedServerHandle()
|
||||||
|
{
|
||||||
|
FakeMxAccessComObject fakeComObject = new(registerHandle: 43);
|
||||||
|
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
||||||
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
|
await session.DispatchAsync(CreateRegisterCommand("register", "client-a"));
|
||||||
|
|
||||||
|
MxCommandReply reply = await session.DispatchAsync(CreateUnregisterCommand("unregister", 43));
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||||
|
Assert.Equal(43, fakeComObject.UnregisteredServerHandle);
|
||||||
|
Assert.Equal(runtime.StaThreadId, fakeComObject.UnregisterThreadId);
|
||||||
|
Assert.Empty(await session.GetRegisteredServerHandlesAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_UnregisterWhenMxAccessThrows_PreservesHResultAndDoesNotRewriteFailure()
|
||||||
|
{
|
||||||
|
const int hresult = unchecked((int)0x80070057);
|
||||||
|
FakeMxAccessComObject fakeComObject = new(
|
||||||
|
registerHandle: 44,
|
||||||
|
unregisterException: new COMException("Invalid handle.", hresult));
|
||||||
|
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
||||||
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
|
await session.DispatchAsync(CreateRegisterCommand("register-before-failure", "client-a"));
|
||||||
|
|
||||||
|
MxCommandReply reply = await session.DispatchAsync(CreateUnregisterCommand("invalid-unregister", 44));
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
|
||||||
|
Assert.True(reply.HasHresult);
|
||||||
|
Assert.Equal(hresult, reply.Hresult);
|
||||||
|
Assert.Contains("0x80070057", reply.DiagnosticMessage);
|
||||||
|
Assert.Equal(44, fakeComObject.UnregisteredServerHandle);
|
||||||
|
|
||||||
|
RegisteredServerHandle registeredServerHandle = Assert.Single(
|
||||||
|
await session.GetRegisteredServerHandlesAsync());
|
||||||
|
Assert.Equal(44, registeredServerHandle.ServerHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_RegisterWithoutPayload_ReturnsInvalidRequest()
|
||||||
|
{
|
||||||
|
FakeMxAccessComObjectFactory factory = new(new FakeMxAccessComObject(registerHandle: 45));
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
||||||
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
|
|
||||||
|
MxCommandReply reply = await session.DispatchAsync(new StaCommand(
|
||||||
|
"session-1",
|
||||||
|
"missing-payload",
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Register,
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
|
||||||
|
Assert.Null(factory.FakeComObject.RegisteredClientName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StaCommand CreateRegisterCommand(
|
||||||
|
string correlationId,
|
||||||
|
string clientName)
|
||||||
|
{
|
||||||
|
return new StaCommand(
|
||||||
|
"session-1",
|
||||||
|
correlationId,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Register,
|
||||||
|
Register = new RegisterCommand
|
||||||
|
{
|
||||||
|
ClientName = clientName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StaCommand CreateUnregisterCommand(
|
||||||
|
string correlationId,
|
||||||
|
int serverHandle)
|
||||||
|
{
|
||||||
|
return new StaCommand(
|
||||||
|
"session-1",
|
||||||
|
correlationId,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Unregister,
|
||||||
|
Unregister = new UnregisterCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StaRuntime CreateRuntime()
|
||||||
|
{
|
||||||
|
return new StaRuntime(
|
||||||
|
new NoopComApartmentInitializer(),
|
||||||
|
new StaMessagePump(),
|
||||||
|
TimeSpan.FromMilliseconds(25));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeMxAccessComObject
|
||||||
|
{
|
||||||
|
private readonly int registerHandle;
|
||||||
|
private readonly Exception? unregisterException;
|
||||||
|
|
||||||
|
public FakeMxAccessComObject(
|
||||||
|
int registerHandle,
|
||||||
|
Exception? unregisterException = null)
|
||||||
|
{
|
||||||
|
this.registerHandle = registerHandle;
|
||||||
|
this.unregisterException = unregisterException;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? RegisteredClientName { get; private set; }
|
||||||
|
|
||||||
|
public int? RegisterThreadId { get; private set; }
|
||||||
|
|
||||||
|
public int? UnregisteredServerHandle { get; private set; }
|
||||||
|
|
||||||
|
public int? UnregisterThreadId { get; private set; }
|
||||||
|
|
||||||
|
public int Register(string clientName)
|
||||||
|
{
|
||||||
|
RegisteredClientName = clientName;
|
||||||
|
RegisterThreadId = Environment.CurrentManagedThreadId;
|
||||||
|
|
||||||
|
return registerHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unregister(int serverHandle)
|
||||||
|
{
|
||||||
|
UnregisteredServerHandle = serverHandle;
|
||||||
|
UnregisterThreadId = Environment.CurrentManagedThreadId;
|
||||||
|
|
||||||
|
if (unregisterException is not null)
|
||||||
|
{
|
||||||
|
throw unregisterException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeMxAccessComObjectFactory : IMxAccessComObjectFactory
|
||||||
|
{
|
||||||
|
public FakeMxAccessComObjectFactory(FakeMxAccessComObject fakeComObject)
|
||||||
|
{
|
||||||
|
FakeComObject = fakeComObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FakeMxAccessComObject FakeComObject { get; }
|
||||||
|
|
||||||
|
public object Create()
|
||||||
|
{
|
||||||
|
return FakeComObject;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NoopEventSink : IMxAccessEventSink
|
||||||
|
{
|
||||||
|
public void Attach(object mxAccessComObject)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Detach()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer
|
||||||
|
{
|
||||||
|
public void Initialize()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Uninitialize()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Worker.MxAccess;
|
||||||
|
using MxGateway.Worker.Sta;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.Tests.MxAccess;
|
||||||
|
|
||||||
|
public sealed class MxAccessLiveComCreationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RegisterAndUnregister_WhenOptedIn_RoundTripsInstalledMxAccessServerHandle()
|
||||||
|
{
|
||||||
|
if (!RunLiveMxAccessTests())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using MxAccessStaSession session = new();
|
||||||
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
|
|
||||||
|
MxCommandReply registerReply = await session.DispatchAsync(new StaCommand(
|
||||||
|
"session-1",
|
||||||
|
"live-register",
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Register,
|
||||||
|
Register = new RegisterCommand
|
||||||
|
{
|
||||||
|
ClientName = "MxGateway.Worker.Tests",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, registerReply.ProtocolStatus.Code);
|
||||||
|
Assert.True(registerReply.Register.ServerHandle > 0);
|
||||||
|
|
||||||
|
MxCommandReply unregisterReply = await session.DispatchAsync(new StaCommand(
|
||||||
|
"session-1",
|
||||||
|
"live-unregister",
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Unregister,
|
||||||
|
Unregister = new UnregisterCommand
|
||||||
|
{
|
||||||
|
ServerHandle = registerReply.Register.ServerHandle,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, unregisterReply.ProtocolStatus.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RunLiveMxAccessTests()
|
||||||
|
{
|
||||||
|
return string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("MXGATEWAY_RUN_LIVE_MXACCESS_TESTS"),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Worker.MxAccess;
|
||||||
|
using MxGateway.Worker.Sta;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.Tests.MxAccess;
|
||||||
|
|
||||||
|
public sealed class MxAccessStaSessionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_CreatesComObjectAndAttachesEventSinkOnStaThread()
|
||||||
|
{
|
||||||
|
FakeMxAccessComObjectFactory factory = new();
|
||||||
|
FakeMxAccessEventSink eventSink = new();
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
using MxAccessStaSession session = new(runtime, factory, eventSink);
|
||||||
|
|
||||||
|
WorkerReady ready = await session.StartAsync(workerProcessId: 1234);
|
||||||
|
|
||||||
|
Assert.Equal(1234, ready.WorkerProcessId);
|
||||||
|
Assert.Equal(MxAccessInteropInfo.ProgId, ready.MxaccessProgid);
|
||||||
|
Assert.Equal(MxAccessInteropInfo.Clsid, ready.MxaccessClsid);
|
||||||
|
Assert.NotNull(ready.ReadyTimestamp);
|
||||||
|
Assert.Equal(runtime.StaThreadId, factory.CreateThreadId);
|
||||||
|
Assert.Equal(runtime.StaThreadId, eventSink.AttachThreadId);
|
||||||
|
Assert.Equal(ApartmentState.STA, factory.CreateApartmentState);
|
||||||
|
Assert.Same(factory.CreatedObject, eventSink.AttachedObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_WhenFactoryFails_MapsCreationExceptionWithHResult()
|
||||||
|
{
|
||||||
|
const int hresult = unchecked((int)0x80040154);
|
||||||
|
FakeMxAccessComObjectFactory factory = new(new COMException("Class not registered.", hresult));
|
||||||
|
FakeMxAccessEventSink eventSink = new();
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
using MxAccessStaSession session = new(runtime, factory, eventSink);
|
||||||
|
|
||||||
|
MxAccessCreationException exception = await Assert.ThrowsAsync<MxAccessCreationException>(
|
||||||
|
() => session.StartAsync(workerProcessId: 1234));
|
||||||
|
|
||||||
|
Assert.Equal(hresult, exception.CapturedHResult);
|
||||||
|
Assert.Equal(MxAccessInteropInfo.ProgId, exception.AttemptedProgId);
|
||||||
|
Assert.Equal(MxAccessInteropInfo.Clsid, exception.AttemptedClsid);
|
||||||
|
Assert.Null(eventSink.AttachedObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispose_DetachesEventSinkOnStaThread()
|
||||||
|
{
|
||||||
|
FakeMxAccessComObjectFactory factory = new();
|
||||||
|
FakeMxAccessEventSink eventSink = new();
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
MxAccessStaSession session = new(runtime, factory, eventSink);
|
||||||
|
await session.StartAsync(workerProcessId: 1234);
|
||||||
|
|
||||||
|
session.Dispose();
|
||||||
|
|
||||||
|
Assert.Equal(runtime.StaThreadId, eventSink.DetachThreadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StaRuntime CreateRuntime()
|
||||||
|
{
|
||||||
|
return new StaRuntime(
|
||||||
|
new NoopComApartmentInitializer(),
|
||||||
|
new StaMessagePump(),
|
||||||
|
TimeSpan.FromMilliseconds(25));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeMxAccessComObjectFactory : IMxAccessComObjectFactory
|
||||||
|
{
|
||||||
|
private readonly Exception? exception;
|
||||||
|
|
||||||
|
public FakeMxAccessComObjectFactory(Exception? exception = null)
|
||||||
|
{
|
||||||
|
this.exception = exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object CreatedObject { get; } = new();
|
||||||
|
|
||||||
|
public int? CreateThreadId { get; private set; }
|
||||||
|
|
||||||
|
public ApartmentState? CreateApartmentState { get; private set; }
|
||||||
|
|
||||||
|
public object Create()
|
||||||
|
{
|
||||||
|
CreateThreadId = Thread.CurrentThread.ManagedThreadId;
|
||||||
|
CreateApartmentState = Thread.CurrentThread.GetApartmentState();
|
||||||
|
|
||||||
|
if (exception is not null)
|
||||||
|
{
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreatedObject;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeMxAccessEventSink : IMxAccessEventSink
|
||||||
|
{
|
||||||
|
public object? AttachedObject { get; private set; }
|
||||||
|
|
||||||
|
public int? AttachThreadId { get; private set; }
|
||||||
|
|
||||||
|
public int? DetachThreadId { get; private set; }
|
||||||
|
|
||||||
|
public void Attach(object mxAccessComObject)
|
||||||
|
{
|
||||||
|
AttachedObject = mxAccessComObject;
|
||||||
|
AttachThreadId = Thread.CurrentThread.ManagedThreadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Detach()
|
||||||
|
{
|
||||||
|
DetachThreadId = Thread.CurrentThread.ManagedThreadId;
|
||||||
|
AttachedObject = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer
|
||||||
|
{
|
||||||
|
public void Initialize()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Uninitialize()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Worker.Sta;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.Tests.Sta;
|
||||||
|
|
||||||
|
public sealed class StaCommandDispatcherTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_ExecutesCommandsOnStaInQueueOrder()
|
||||||
|
{
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
runtime.Start();
|
||||||
|
RecordingCommandExecutor executor = new();
|
||||||
|
StaCommandDispatcher dispatcher = new(runtime, executor);
|
||||||
|
|
||||||
|
Task<MxCommandReply> first = dispatcher.DispatchAsync(CreateCommand("correlation-1", MxCommandKind.Register));
|
||||||
|
Task<MxCommandReply> second = dispatcher.DispatchAsync(CreateCommand("correlation-2", MxCommandKind.AddItem));
|
||||||
|
|
||||||
|
MxCommandReply[] replies = await Task.WhenAll(first, second);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "correlation-1", "correlation-2" }, executor.CorrelationIds);
|
||||||
|
Assert.All(executor.ThreadIds, threadId => Assert.Equal(runtime.StaThreadId, threadId));
|
||||||
|
Assert.Equal("correlation-1", replies[0].CorrelationId);
|
||||||
|
Assert.Equal("correlation-2", replies[1].CorrelationId);
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, replies[0].ProtocolStatus.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_WhenExecutorThrows_ReturnsFailureReplyWithHResult()
|
||||||
|
{
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
runtime.Start();
|
||||||
|
StaCommandDispatcher dispatcher = new(
|
||||||
|
runtime,
|
||||||
|
new ThrowingCommandExecutor(new COMException("provider detail", unchecked((int)0x80070057))));
|
||||||
|
|
||||||
|
MxCommandReply reply = await dispatcher.DispatchAsync(CreateCommand("correlation-1", MxCommandKind.Register));
|
||||||
|
|
||||||
|
Assert.Equal("session-1", reply.SessionId);
|
||||||
|
Assert.Equal("correlation-1", reply.CorrelationId);
|
||||||
|
Assert.Equal(MxCommandKind.Register, reply.Kind);
|
||||||
|
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
|
||||||
|
Assert.Equal(unchecked((int)0x80070057), reply.Hresult);
|
||||||
|
Assert.Contains("0x80070057", reply.DiagnosticMessage);
|
||||||
|
Assert.DoesNotContain("provider detail", reply.DiagnosticMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_WhenCanceledBeforeExecution_ReturnsCanceledReplyWithoutExecuting()
|
||||||
|
{
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
runtime.Start();
|
||||||
|
BlockingCommandExecutor executor = new();
|
||||||
|
StaCommandDispatcher dispatcher = new(runtime, executor);
|
||||||
|
Task<MxCommandReply> blocked = dispatcher.DispatchAsync(CreateCommand("blocked", MxCommandKind.Register));
|
||||||
|
Assert.True(executor.Started.Wait(TimeSpan.FromSeconds(2)));
|
||||||
|
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
Task<MxCommandReply> canceled = dispatcher.DispatchAsync(
|
||||||
|
CreateCommand("canceled", MxCommandKind.AddItem, cancellation.Token));
|
||||||
|
cancellation.Cancel();
|
||||||
|
|
||||||
|
executor.Release();
|
||||||
|
MxCommandReply canceledReply = await canceled;
|
||||||
|
await blocked;
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Canceled, canceledReply.ProtocolStatus.Code);
|
||||||
|
Assert.DoesNotContain("canceled", executor.CorrelationIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_WhenCanceledAfterExecutionStarts_StillReturnsLateReply()
|
||||||
|
{
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
runtime.Start();
|
||||||
|
BlockingCommandExecutor executor = new();
|
||||||
|
StaCommandDispatcher dispatcher = new(runtime, executor);
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
|
||||||
|
Task<MxCommandReply> replyTask = dispatcher.DispatchAsync(
|
||||||
|
CreateCommand("late-reply", MxCommandKind.Register, cancellation.Token));
|
||||||
|
|
||||||
|
Assert.True(executor.Started.Wait(TimeSpan.FromSeconds(2)));
|
||||||
|
cancellation.Cancel();
|
||||||
|
executor.Release();
|
||||||
|
|
||||||
|
MxCommandReply reply = await replyTask;
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||||
|
Assert.Contains("late-reply", executor.CorrelationIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DispatchAsync_WhenShutdownRequested_RejectsNewCommands()
|
||||||
|
{
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
runtime.Start();
|
||||||
|
StaCommandDispatcher dispatcher = new(runtime, new RecordingCommandExecutor());
|
||||||
|
|
||||||
|
dispatcher.RequestShutdown();
|
||||||
|
MxCommandReply reply = await dispatcher.DispatchAsync(CreateCommand("correlation-1", MxCommandKind.Register));
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.WorkerUnavailable, reply.ProtocolStatus.Code);
|
||||||
|
Assert.Equal("correlation-1", reply.CorrelationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PopulateHeartbeat_ReportsCurrentCorrelationAndPendingCount()
|
||||||
|
{
|
||||||
|
using StaRuntime runtime = CreateRuntime();
|
||||||
|
runtime.Start();
|
||||||
|
BlockingCommandExecutor executor = new();
|
||||||
|
StaCommandDispatcher dispatcher = new(runtime, executor);
|
||||||
|
|
||||||
|
Task<MxCommandReply> current = dispatcher.DispatchAsync(CreateCommand("current", MxCommandKind.Register));
|
||||||
|
Assert.True(executor.Started.Wait(TimeSpan.FromSeconds(2)));
|
||||||
|
Task<MxCommandReply> pending = dispatcher.DispatchAsync(CreateCommand("pending", MxCommandKind.AddItem));
|
||||||
|
|
||||||
|
WorkerHeartbeat heartbeat = new();
|
||||||
|
dispatcher.PopulateHeartbeat(heartbeat);
|
||||||
|
|
||||||
|
Assert.Equal("current", heartbeat.CurrentCommandCorrelationId);
|
||||||
|
Assert.Equal(1u, heartbeat.PendingCommandCount);
|
||||||
|
|
||||||
|
executor.Release();
|
||||||
|
await Task.WhenAll(current, pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StaCommand CreateCommand(
|
||||||
|
string correlationId,
|
||||||
|
MxCommandKind kind,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return new StaCommand(
|
||||||
|
"session-1",
|
||||||
|
correlationId,
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = kind,
|
||||||
|
Ping = new PingCommand
|
||||||
|
{
|
||||||
|
Message = correlationId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StaRuntime CreateRuntime()
|
||||||
|
{
|
||||||
|
return new StaRuntime(
|
||||||
|
new NoopComApartmentInitializer(),
|
||||||
|
new StaMessagePump(),
|
||||||
|
TimeSpan.FromMilliseconds(25));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingCommandExecutor : IStaCommandExecutor
|
||||||
|
{
|
||||||
|
private readonly object gate = new();
|
||||||
|
private readonly List<string> correlationIds = new();
|
||||||
|
private readonly List<int> threadIds = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<string> CorrelationIds
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return correlationIds.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<int> ThreadIds
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return threadIds.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxCommandReply Execute(StaCommand command)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
correlationIds.Add(command.CorrelationId);
|
||||||
|
threadIds.Add(Thread.CurrentThread.ManagedThreadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MxCommandReply
|
||||||
|
{
|
||||||
|
ProtocolStatus = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.Ok,
|
||||||
|
Message = "OK",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BlockingCommandExecutor : IStaCommandExecutor
|
||||||
|
{
|
||||||
|
private readonly ManualResetEventSlim release = new(false);
|
||||||
|
private readonly object gate = new();
|
||||||
|
private readonly List<string> correlationIds = new();
|
||||||
|
|
||||||
|
public ManualResetEventSlim Started { get; } = new(false);
|
||||||
|
|
||||||
|
public IReadOnlyList<string> CorrelationIds
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return correlationIds.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxCommandReply Execute(StaCommand command)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
correlationIds.Add(command.CorrelationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Started.Set();
|
||||||
|
release.Wait(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
return new MxCommandReply
|
||||||
|
{
|
||||||
|
ProtocolStatus = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.Ok,
|
||||||
|
Message = "OK",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Release()
|
||||||
|
{
|
||||||
|
release.Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ThrowingCommandExecutor : IStaCommandExecutor
|
||||||
|
{
|
||||||
|
private readonly Exception exception;
|
||||||
|
|
||||||
|
public ThrowingCommandExecutor(Exception exception)
|
||||||
|
{
|
||||||
|
this.exception = exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxCommandReply Execute(StaCommand command)
|
||||||
|
{
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer
|
||||||
|
{
|
||||||
|
public void Initialize()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Uninitialize()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.Conversion;
|
||||||
|
|
||||||
|
public sealed class HResultConversion
|
||||||
|
{
|
||||||
|
public HResultConversion(
|
||||||
|
int hresult,
|
||||||
|
ProtocolStatus protocolStatus,
|
||||||
|
string diagnosticMessage)
|
||||||
|
{
|
||||||
|
HResult = hresult;
|
||||||
|
ProtocolStatus = protocolStatus;
|
||||||
|
DiagnosticMessage = diagnosticMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int HResult { get; }
|
||||||
|
|
||||||
|
public ProtocolStatus ProtocolStatus { get; }
|
||||||
|
|
||||||
|
public string DiagnosticMessage { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.Conversion;
|
||||||
|
|
||||||
|
public sealed class HResultConverter
|
||||||
|
{
|
||||||
|
public HResultConversion Convert(Exception exception)
|
||||||
|
{
|
||||||
|
if (exception is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(exception));
|
||||||
|
}
|
||||||
|
|
||||||
|
int hresult = exception is COMException comException
|
||||||
|
? comException.ErrorCode
|
||||||
|
: exception.HResult;
|
||||||
|
|
||||||
|
return new HResultConversion(
|
||||||
|
hresult,
|
||||||
|
CreateProtocolStatus(hresult, exception),
|
||||||
|
CreateSafeDiagnosticMessage(exception));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProtocolStatus CreateProtocolStatus(
|
||||||
|
int hresult,
|
||||||
|
Exception? exception = null)
|
||||||
|
{
|
||||||
|
return new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = hresult == 0 ? ProtocolStatusCode.Ok : ProtocolStatusCode.MxaccessFailure,
|
||||||
|
Message = exception is null
|
||||||
|
? FormatHResult(hresult)
|
||||||
|
: $"{exception.GetType().Name}: {FormatHResult(hresult)}",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateSafeDiagnosticMessage(Exception exception)
|
||||||
|
{
|
||||||
|
return $"{exception.GetType().FullName}: {FormatHResult(exception.HResult)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatHResult(int hresult)
|
||||||
|
{
|
||||||
|
return $"HRESULT 0x{unchecked((uint)hresult):X8}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.Conversion;
|
||||||
|
|
||||||
|
public sealed class MxStatusConversionException : Exception
|
||||||
|
{
|
||||||
|
public MxStatusConversionException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.Conversion;
|
||||||
|
|
||||||
|
internal static class MxStatusDetailText
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<int, string> KnownDetails = new Dictionary<int, string>
|
||||||
|
{
|
||||||
|
[16] = "Request timed out",
|
||||||
|
[17] = "Platform communication error",
|
||||||
|
[18] = "Invalid platform ID",
|
||||||
|
[19] = "Invalid engine ID",
|
||||||
|
[20] = "Engine communication error",
|
||||||
|
[21] = "Invalid reference",
|
||||||
|
[22] = "No Galaxy Repository",
|
||||||
|
[23] = "Invalid object ID",
|
||||||
|
[24] = "Object signature mismatch",
|
||||||
|
[25] = "Invalid primitive ID",
|
||||||
|
[26] = "Invalid attribute ID",
|
||||||
|
[27] = "Invalid property ID",
|
||||||
|
[28] = "Index out of range",
|
||||||
|
[29] = "Data out of range",
|
||||||
|
[30] = "Incorrect data type",
|
||||||
|
[31] = "Attribute not readable",
|
||||||
|
[32] = "Attribute not writeable",
|
||||||
|
[33] = "Write access denied",
|
||||||
|
[34] = "Unknown error",
|
||||||
|
[36] = "Wrong data type",
|
||||||
|
[37] = "Wrong number of dimensions",
|
||||||
|
[38] = "Invalid index",
|
||||||
|
[39] = "Index out of order",
|
||||||
|
[40] = "Dimension does not exist",
|
||||||
|
[41] = "Conversion not supported",
|
||||||
|
[42] = "Unable to convert string",
|
||||||
|
[43] = "Overflow",
|
||||||
|
[44] = "Attribute signature mismatch",
|
||||||
|
[47] = "Nmx version mismatch",
|
||||||
|
[48] = "Nmx command not valid",
|
||||||
|
[49] = "Lmx version mismatch",
|
||||||
|
[50] = "Lmx command not valid",
|
||||||
|
[56] = "Secured Write",
|
||||||
|
[57] = "Verified Write",
|
||||||
|
[60] = "User did not have the necessary permissions to write",
|
||||||
|
[61] = "Verifier did not have the necessary permissions to verify",
|
||||||
|
[541] = "Conversion to intended data type is not supported",
|
||||||
|
[542] = "Unable to convert the input string to intended data type",
|
||||||
|
[8017] = "Object must be offscan to modify attributes that have an MxSecurityConfigure security classification",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string Lookup(int detail)
|
||||||
|
{
|
||||||
|
return KnownDetails.TryGetValue(detail, out string text) ? text : string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Reflection;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.Conversion;
|
||||||
|
|
||||||
|
public sealed class MxStatusProxyConverter
|
||||||
|
{
|
||||||
|
public MxStatusProxy Convert(object status)
|
||||||
|
{
|
||||||
|
if (status is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
Type statusType = status.GetType();
|
||||||
|
int success = ReadInt32Field(status, statusType, "success");
|
||||||
|
int rawCategory = ReadInt32Field(status, statusType, "category");
|
||||||
|
int rawDetectedBy = ReadInt32Field(status, statusType, "detectedBy");
|
||||||
|
int detail = ReadInt32Field(status, statusType, "detail");
|
||||||
|
|
||||||
|
return new MxStatusProxy
|
||||||
|
{
|
||||||
|
Success = success,
|
||||||
|
Category = MapCategory(rawCategory),
|
||||||
|
DetectedBy = MapSource(rawDetectedBy),
|
||||||
|
Detail = detail,
|
||||||
|
RawCategory = rawCategory,
|
||||||
|
RawDetectedBy = rawDetectedBy,
|
||||||
|
DiagnosticText = MxStatusDetailText.Lookup(detail),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<MxStatusProxy> ConvertMany(Array? statuses)
|
||||||
|
{
|
||||||
|
if (statuses is null)
|
||||||
|
{
|
||||||
|
return Array.Empty<MxStatusProxy>();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MxStatusProxy> converted = new(statuses.Length);
|
||||||
|
foreach (object? status in statuses)
|
||||||
|
{
|
||||||
|
if (status is null)
|
||||||
|
{
|
||||||
|
converted.Add(new MxStatusProxy
|
||||||
|
{
|
||||||
|
Category = MxStatusCategory.Unknown,
|
||||||
|
DetectedBy = MxStatusSource.Unknown,
|
||||||
|
DiagnosticText = "Null MXSTATUS_PROXY entry.",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
converted.Add(Convert(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
return converted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string PreserveCompletionOnlyStatusBytes(byte[] statusBytes)
|
||||||
|
{
|
||||||
|
if (statusBytes is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(statusBytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"completion_only_status_hex={BitConverter.ToString(statusBytes).Replace("-", string.Empty)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadInt32Field(
|
||||||
|
object value,
|
||||||
|
Type valueType,
|
||||||
|
string fieldName)
|
||||||
|
{
|
||||||
|
FieldInfo? field = valueType.GetField(fieldName, BindingFlags.Instance | BindingFlags.Public);
|
||||||
|
if (field is null)
|
||||||
|
{
|
||||||
|
throw new MxStatusConversionException(
|
||||||
|
$"Status object type '{valueType.FullName}' does not expose required field '{fieldName}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
object? fieldValue = field.GetValue(value);
|
||||||
|
if (fieldValue is null)
|
||||||
|
{
|
||||||
|
throw new MxStatusConversionException(
|
||||||
|
$"Status object field '{fieldName}' on type '{valueType.FullName}' is null.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return System.Convert.ToInt32(fieldValue, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxStatusCategory MapCategory(int rawCategory)
|
||||||
|
{
|
||||||
|
return rawCategory switch
|
||||||
|
{
|
||||||
|
-1 => MxStatusCategory.Unknown,
|
||||||
|
0 => MxStatusCategory.Ok,
|
||||||
|
1 => MxStatusCategory.Pending,
|
||||||
|
2 => MxStatusCategory.Warning,
|
||||||
|
3 => MxStatusCategory.CommunicationError,
|
||||||
|
4 => MxStatusCategory.ConfigurationError,
|
||||||
|
5 => MxStatusCategory.OperationalError,
|
||||||
|
6 => MxStatusCategory.SecurityError,
|
||||||
|
7 => MxStatusCategory.SoftwareError,
|
||||||
|
8 => MxStatusCategory.OtherError,
|
||||||
|
_ => MxStatusCategory.Unknown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxStatusSource MapSource(int rawDetectedBy)
|
||||||
|
{
|
||||||
|
return rawDetectedBy switch
|
||||||
|
{
|
||||||
|
-1 => MxStatusSource.Unknown,
|
||||||
|
0 => MxStatusSource.RequestingLmx,
|
||||||
|
1 => MxStatusSource.RespondingLmx,
|
||||||
|
2 => MxStatusSource.RequestingNmx,
|
||||||
|
3 => MxStatusSource.RespondingNmx,
|
||||||
|
4 => MxStatusSource.RequestingAutomationObject,
|
||||||
|
5 => MxStatusSource.RespondingAutomationObject,
|
||||||
|
_ => MxStatusSource.Unknown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ public sealed class WorkerPipeSession
|
|||||||
private readonly Func<int> _processIdProvider;
|
private readonly Func<int> _processIdProvider;
|
||||||
private readonly WorkerFrameReader _reader;
|
private readonly WorkerFrameReader _reader;
|
||||||
private readonly WorkerFrameWriter _writer;
|
private readonly WorkerFrameWriter _writer;
|
||||||
|
private MxAccessStaSession? _mxAccessStaSession;
|
||||||
private long _nextSequence;
|
private long _nextSequence;
|
||||||
|
|
||||||
public WorkerPipeSession(
|
public WorkerPipeSession(
|
||||||
@@ -42,7 +43,7 @@ public sealed class WorkerPipeSession
|
|||||||
|
|
||||||
public Task CompleteStartupHandshakeAsync(CancellationToken cancellationToken = default)
|
public Task CompleteStartupHandshakeAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return CompleteStartupHandshakeAsync(_ => Task.CompletedTask, cancellationToken);
|
return CompleteStartupHandshakeAsync(InitializeMxAccessAsync, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CompleteStartupHandshakeAsync(
|
public async Task CompleteStartupHandshakeAsync(
|
||||||
@@ -54,20 +55,44 @@ public sealed class WorkerPipeSession
|
|||||||
throw new ArgumentNullException(nameof(initializeMxAccessAsync));
|
throw new ArgumentNullException(nameof(initializeMxAccessAsync));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await CompleteStartupHandshakeAsync(
|
||||||
|
async innerCancellationToken =>
|
||||||
|
{
|
||||||
|
await initializeMxAccessAsync(innerCancellationToken).ConfigureAwait(false);
|
||||||
|
return CreateWorkerReady();
|
||||||
|
},
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CompleteStartupHandshakeAsync(
|
||||||
|
Func<CancellationToken, Task<WorkerReady>> initializeMxAccessAsync,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (initializeMxAccessAsync is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(initializeMxAccessAsync));
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
WorkerEnvelope envelope = await _reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
WorkerEnvelope envelope = await _reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
ValidateGatewayHello(envelope);
|
ValidateGatewayHello(envelope);
|
||||||
|
|
||||||
await WriteWorkerHelloAsync(cancellationToken).ConfigureAwait(false);
|
await WriteWorkerHelloAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await initializeMxAccessAsync(cancellationToken).ConfigureAwait(false);
|
WorkerReady ready = await initializeMxAccessAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await WriteWorkerReadyAsync(cancellationToken).ConfigureAwait(false);
|
await WriteWorkerReadyAsync(ready, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (WorkerFrameProtocolException exception)
|
catch (WorkerFrameProtocolException exception)
|
||||||
{
|
{
|
||||||
await TryWriteFaultAsync(exception, cancellationToken).ConfigureAwait(false);
|
await TryWriteFaultAsync(exception, cancellationToken).ConfigureAwait(false);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
await TryWriteFaultAsync(MxAccessCreationException.From(exception), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ValidateGatewayHello(WorkerEnvelope envelope)
|
private void ValidateGatewayHello(WorkerEnvelope envelope)
|
||||||
@@ -108,17 +133,11 @@ public sealed class WorkerPipeSession
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task WriteWorkerReadyAsync(CancellationToken cancellationToken)
|
private Task WriteWorkerReadyAsync(
|
||||||
|
WorkerReady ready,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return _writer.WriteAsync(
|
return _writer.WriteAsync(CreateEnvelope(ready), cancellationToken);
|
||||||
CreateEnvelope(new WorkerReady
|
|
||||||
{
|
|
||||||
WorkerProcessId = _processIdProvider(),
|
|
||||||
MxaccessProgid = MxAccessInteropInfo.ProgId,
|
|
||||||
MxaccessClsid = MxAccessInteropInfo.Clsid,
|
|
||||||
ReadyTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
|
||||||
}),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task TryWriteFaultAsync(
|
private async Task TryWriteFaultAsync(
|
||||||
@@ -140,6 +159,25 @@ public sealed class WorkerPipeSession
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task TryWriteFaultAsync(
|
||||||
|
MxAccessCreationException exception,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _writer
|
||||||
|
.WriteAsync(CreateEnvelope(CreateFault(exception)), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception faultWriteException) when (
|
||||||
|
faultWriteException is IOException
|
||||||
|
|| faultWriteException is ObjectDisposedException
|
||||||
|
|| faultWriteException is WorkerFrameProtocolException)
|
||||||
|
{
|
||||||
|
// The MXAccess creation failure is the actionable error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private WorkerEnvelope CreateEnvelope(WorkerHello hello)
|
private WorkerEnvelope CreateEnvelope(WorkerHello hello)
|
||||||
{
|
{
|
||||||
return CreateBaseEnvelope(hello);
|
return CreateBaseEnvelope(hello);
|
||||||
@@ -191,6 +229,34 @@ public sealed class WorkerPipeSession
|
|||||||
return unchecked((ulong)Interlocked.Increment(ref _nextSequence));
|
return unchecked((ulong)Interlocked.Increment(ref _nextSequence));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<WorkerReady> InitializeMxAccessAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_mxAccessStaSession = new MxAccessStaSession();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _mxAccessStaSession
|
||||||
|
.StartAsync(_processIdProvider(), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_mxAccessStaSession.Dispose();
|
||||||
|
_mxAccessStaSession = null;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkerReady CreateWorkerReady()
|
||||||
|
{
|
||||||
|
return new WorkerReady
|
||||||
|
{
|
||||||
|
WorkerProcessId = _processIdProvider(),
|
||||||
|
MxaccessProgid = MxAccessInteropInfo.ProgId,
|
||||||
|
MxaccessClsid = MxAccessInteropInfo.Clsid,
|
||||||
|
ReadyTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static WorkerFault CreateFault(WorkerFrameProtocolException exception)
|
private static WorkerFault CreateFault(WorkerFrameProtocolException exception)
|
||||||
{
|
{
|
||||||
return new WorkerFault
|
return new WorkerFault
|
||||||
@@ -206,6 +272,29 @@ public sealed class WorkerPipeSession
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static WorkerFault CreateFault(MxAccessCreationException exception)
|
||||||
|
{
|
||||||
|
WorkerFault fault = new()
|
||||||
|
{
|
||||||
|
Category = WorkerFaultCategory.MxaccessCreationFailed,
|
||||||
|
ExceptionType = exception.InnerException?.GetType().FullName ?? exception.GetType().FullName ?? string.Empty,
|
||||||
|
DiagnosticMessage = exception.Message,
|
||||||
|
ProtocolStatus = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.WorkerUnavailable,
|
||||||
|
Message = exception.Message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
int? hresult = MxAccessCreationException.ExtractHResult(exception);
|
||||||
|
if (hresult.HasValue)
|
||||||
|
{
|
||||||
|
fault.Hresult = hresult.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fault;
|
||||||
|
}
|
||||||
|
|
||||||
private static WorkerFaultCategory MapFaultCategory(WorkerFrameProtocolErrorCode errorCode)
|
private static WorkerFaultCategory MapFaultCategory(WorkerFrameProtocolErrorCode errorCode)
|
||||||
{
|
{
|
||||||
return errorCode switch
|
return errorCode switch
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
public interface IMxAccessComObjectFactory
|
||||||
|
{
|
||||||
|
object Create();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
public interface IMxAccessEventSink
|
||||||
|
{
|
||||||
|
void Attach(object mxAccessComObject);
|
||||||
|
|
||||||
|
void Detach();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
public interface IMxAccessServer
|
||||||
|
{
|
||||||
|
int Register(string clientName);
|
||||||
|
|
||||||
|
void Unregister(int serverHandle);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using ArchestrA.MxAccess;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
public sealed class MxAccessBaseEventSink : IMxAccessEventSink
|
||||||
|
{
|
||||||
|
private LMXProxyServerClass? server;
|
||||||
|
|
||||||
|
public void Attach(object mxAccessComObject)
|
||||||
|
{
|
||||||
|
server = (LMXProxyServerClass)mxAccessComObject;
|
||||||
|
server.OnDataChange += OnDataChange;
|
||||||
|
server.OnWriteComplete += OnWriteComplete;
|
||||||
|
server.OperationComplete += OperationComplete;
|
||||||
|
server.OnBufferedDataChange += OnBufferedDataChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Detach()
|
||||||
|
{
|
||||||
|
if (server is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
server.OnDataChange -= OnDataChange;
|
||||||
|
server.OnWriteComplete -= OnWriteComplete;
|
||||||
|
server.OperationComplete -= OperationComplete;
|
||||||
|
server.OnBufferedDataChange -= OnBufferedDataChange;
|
||||||
|
server = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnDataChange(
|
||||||
|
int hLMXServerHandle,
|
||||||
|
int phItemHandle,
|
||||||
|
object pvItemValue,
|
||||||
|
int pwItemQuality,
|
||||||
|
object pftItemTimeStamp,
|
||||||
|
ref MXSTATUS_PROXY[] pVars)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnWriteComplete(
|
||||||
|
int hLMXServerHandle,
|
||||||
|
int phItemHandle,
|
||||||
|
ref MXSTATUS_PROXY[] pVars)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OperationComplete(
|
||||||
|
int hLMXServerHandle,
|
||||||
|
int phItemHandle,
|
||||||
|
ref MXSTATUS_PROXY[] pVars)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnBufferedDataChange(
|
||||||
|
int hLMXServerHandle,
|
||||||
|
int phItemHandle,
|
||||||
|
MxDataType dtDataType,
|
||||||
|
object pvItemValue,
|
||||||
|
object pwItemQuality,
|
||||||
|
object pftItemTimeStamp,
|
||||||
|
ref MXSTATUS_PROXY[] pVars)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using ArchestrA.MxAccess;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
public sealed class MxAccessComObjectFactory : IMxAccessComObjectFactory
|
||||||
|
{
|
||||||
|
public object Create()
|
||||||
|
{
|
||||||
|
return new LMXProxyServerClass();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.ExceptionServices;
|
||||||
|
using ArchestrA.MxAccess;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
public sealed class MxAccessComServer : IMxAccessServer
|
||||||
|
{
|
||||||
|
private readonly object mxAccessComObject;
|
||||||
|
|
||||||
|
public MxAccessComServer(object mxAccessComObject)
|
||||||
|
{
|
||||||
|
this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Register(string clientName)
|
||||||
|
{
|
||||||
|
if (mxAccessComObject is ILMXProxyServer mxAccessServer)
|
||||||
|
{
|
||||||
|
return mxAccessServer.Register(clientName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int)Invoke(nameof(Register), clientName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unregister(int serverHandle)
|
||||||
|
{
|
||||||
|
if (mxAccessComObject is ILMXProxyServer mxAccessServer)
|
||||||
|
{
|
||||||
|
mxAccessServer.Unregister(serverHandle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke(nameof(Unregister), serverHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private object Invoke(
|
||||||
|
string methodName,
|
||||||
|
params object[] arguments)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return mxAccessComObject
|
||||||
|
.GetType()
|
||||||
|
.InvokeMember(
|
||||||
|
methodName,
|
||||||
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.InvokeMethod,
|
||||||
|
binder: null,
|
||||||
|
target: mxAccessComObject,
|
||||||
|
args: arguments);
|
||||||
|
}
|
||||||
|
catch (TargetInvocationException exception) when (exception.InnerException is not null)
|
||||||
|
{
|
||||||
|
ExceptionDispatchInfo.Capture(exception.InnerException).Throw();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using System;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Worker.Conversion;
|
||||||
|
using MxGateway.Worker.Sta;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
||||||
|
{
|
||||||
|
private readonly MxAccessSession session;
|
||||||
|
private readonly VariantConverter variantConverter;
|
||||||
|
|
||||||
|
public MxAccessCommandExecutor(MxAccessSession session)
|
||||||
|
: this(session, new VariantConverter())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxAccessCommandExecutor(
|
||||||
|
MxAccessSession session,
|
||||||
|
VariantConverter variantConverter)
|
||||||
|
{
|
||||||
|
this.session = session ?? throw new ArgumentNullException(nameof(session));
|
||||||
|
this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxCommandReply Execute(StaCommand command)
|
||||||
|
{
|
||||||
|
if (command is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(command));
|
||||||
|
}
|
||||||
|
|
||||||
|
return command.Kind switch
|
||||||
|
{
|
||||||
|
MxCommandKind.Register => ExecuteRegister(command),
|
||||||
|
MxCommandKind.Unregister => ExecuteUnregister(command),
|
||||||
|
_ => CreateInvalidRequestReply(command, $"Unsupported MXAccess command kind {command.Kind}."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private MxCommandReply ExecuteRegister(StaCommand command)
|
||||||
|
{
|
||||||
|
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Register)
|
||||||
|
{
|
||||||
|
return CreateInvalidRequestReply(command, "Register command payload is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int serverHandle = session.Register(command.Command.Register.ClientName);
|
||||||
|
MxCommandReply reply = CreateOkReply(command);
|
||||||
|
reply.ReturnValue = variantConverter.Convert(serverHandle);
|
||||||
|
reply.Register = new RegisterReply
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
};
|
||||||
|
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MxCommandReply ExecuteUnregister(StaCommand command)
|
||||||
|
{
|
||||||
|
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Unregister)
|
||||||
|
{
|
||||||
|
return CreateInvalidRequestReply(command, "Unregister command payload is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Unregister(command.Command.Unregister.ServerHandle);
|
||||||
|
return CreateOkReply(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommandReply CreateOkReply(StaCommand command)
|
||||||
|
{
|
||||||
|
return new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = command.SessionId,
|
||||||
|
CorrelationId = command.CorrelationId,
|
||||||
|
Kind = command.Kind,
|
||||||
|
Hresult = 0,
|
||||||
|
ProtocolStatus = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.Ok,
|
||||||
|
Message = "OK",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommandReply CreateInvalidRequestReply(
|
||||||
|
StaCommand command,
|
||||||
|
string message)
|
||||||
|
{
|
||||||
|
return new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = command.SessionId,
|
||||||
|
CorrelationId = command.CorrelationId,
|
||||||
|
Kind = command.Kind,
|
||||||
|
ProtocolStatus = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.InvalidRequest,
|
||||||
|
Message = message,
|
||||||
|
},
|
||||||
|
DiagnosticMessage = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
public sealed class MxAccessCreationException : Exception
|
||||||
|
{
|
||||||
|
public MxAccessCreationException(Exception innerException)
|
||||||
|
: base(
|
||||||
|
$"Failed to create MXAccess COM object {MxAccessInteropInfo.ComClassName} ({MxAccessInteropInfo.ProgId}).",
|
||||||
|
innerException)
|
||||||
|
{
|
||||||
|
AttemptedProgId = MxAccessInteropInfo.ProgId;
|
||||||
|
AttemptedClsid = MxAccessInteropInfo.Clsid;
|
||||||
|
AttemptedComClassName = MxAccessInteropInfo.ComClassName;
|
||||||
|
HResult = innerException.HResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string AttemptedProgId { get; }
|
||||||
|
|
||||||
|
public string AttemptedClsid { get; }
|
||||||
|
|
||||||
|
public string AttemptedComClassName { get; }
|
||||||
|
|
||||||
|
public int? CapturedHResult => HResult == 0 ? null : HResult;
|
||||||
|
|
||||||
|
public static MxAccessCreationException From(Exception exception)
|
||||||
|
{
|
||||||
|
return exception is MxAccessCreationException creationException
|
||||||
|
? creationException
|
||||||
|
: new MxAccessCreationException(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int? ExtractHResult(Exception exception)
|
||||||
|
{
|
||||||
|
if (exception is MxAccessCreationException creationException)
|
||||||
|
{
|
||||||
|
return creationException.CapturedHResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exception is COMException comException)
|
||||||
|
{
|
||||||
|
return comException.HResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return exception.HResult == 0 ? null : exception.HResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
public sealed class MxAccessHandleRegistry
|
||||||
|
{
|
||||||
|
private readonly Dictionary<int, RegisteredServerHandle> serverHandles = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<RegisteredServerHandle> ServerHandles => serverHandles
|
||||||
|
.Values
|
||||||
|
.OrderBy(handle => handle.ServerHandle)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
public void RegisterServerHandle(
|
||||||
|
int serverHandle,
|
||||||
|
string clientName)
|
||||||
|
{
|
||||||
|
serverHandles[serverHandle] = new RegisteredServerHandle(serverHandle, clientName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UnregisterServerHandle(int serverHandle)
|
||||||
|
{
|
||||||
|
serverHandles.Remove(serverHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ContainsServerHandle(int serverHandle)
|
||||||
|
{
|
||||||
|
return serverHandles.ContainsKey(serverHandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
public sealed class MxAccessSession : IDisposable
|
||||||
|
{
|
||||||
|
private readonly object mxAccessComObject;
|
||||||
|
private readonly IMxAccessServer mxAccessServer;
|
||||||
|
private readonly IMxAccessEventSink eventSink;
|
||||||
|
private readonly MxAccessHandleRegistry handleRegistry;
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
private MxAccessSession(
|
||||||
|
object mxAccessComObject,
|
||||||
|
IMxAccessServer mxAccessServer,
|
||||||
|
IMxAccessEventSink eventSink,
|
||||||
|
MxAccessHandleRegistry handleRegistry,
|
||||||
|
int creationThreadId)
|
||||||
|
{
|
||||||
|
this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject));
|
||||||
|
this.mxAccessServer = mxAccessServer ?? throw new ArgumentNullException(nameof(mxAccessServer));
|
||||||
|
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
|
||||||
|
this.handleRegistry = handleRegistry ?? throw new ArgumentNullException(nameof(handleRegistry));
|
||||||
|
CreationThreadId = creationThreadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int CreationThreadId { get; }
|
||||||
|
|
||||||
|
public MxAccessHandleRegistry HandleRegistry => handleRegistry;
|
||||||
|
|
||||||
|
public WorkerReady CreateWorkerReady(int workerProcessId)
|
||||||
|
{
|
||||||
|
return new WorkerReady
|
||||||
|
{
|
||||||
|
WorkerProcessId = workerProcessId,
|
||||||
|
MxaccessProgid = MxAccessInteropInfo.ProgId,
|
||||||
|
MxaccessClsid = MxAccessInteropInfo.Clsid,
|
||||||
|
ReadyTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MxAccessSession Create(
|
||||||
|
IMxAccessComObjectFactory factory,
|
||||||
|
IMxAccessEventSink eventSink)
|
||||||
|
{
|
||||||
|
if (factory is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(factory));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventSink is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(eventSink));
|
||||||
|
}
|
||||||
|
|
||||||
|
object? mxAccessComObject = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mxAccessComObject = factory.Create();
|
||||||
|
if (mxAccessComObject is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("MXAccess COM factory returned null.");
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSink.Attach(mxAccessComObject);
|
||||||
|
|
||||||
|
return new MxAccessSession(
|
||||||
|
mxAccessComObject,
|
||||||
|
new MxAccessComServer(mxAccessComObject),
|
||||||
|
eventSink,
|
||||||
|
new MxAccessHandleRegistry(),
|
||||||
|
Environment.CurrentManagedThreadId);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
eventSink.Detach();
|
||||||
|
|
||||||
|
if (mxAccessComObject is not null && Marshal.IsComObject(mxAccessComObject))
|
||||||
|
{
|
||||||
|
Marshal.FinalReleaseComObject(mxAccessComObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw MxAccessCreationException.From(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Register(string clientName)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
int serverHandle = mxAccessServer.Register(clientName);
|
||||||
|
handleRegistry.RegisterServerHandle(serverHandle, clientName);
|
||||||
|
|
||||||
|
return serverHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unregister(int serverHandle)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
mxAccessServer.Unregister(serverHandle);
|
||||||
|
handleRegistry.UnregisterServerHandle(serverHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
eventSink.Detach();
|
||||||
|
|
||||||
|
if (Marshal.IsComObject(mxAccessComObject))
|
||||||
|
{
|
||||||
|
Marshal.FinalReleaseComObject(mxAccessComObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(MxAccessSession));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Worker.Sta;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
public sealed class MxAccessStaSession : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IMxAccessComObjectFactory factory;
|
||||||
|
private readonly IMxAccessEventSink eventSink;
|
||||||
|
private readonly StaRuntime staRuntime;
|
||||||
|
private StaCommandDispatcher? commandDispatcher;
|
||||||
|
private MxAccessSession? session;
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
public MxAccessStaSession()
|
||||||
|
: this(
|
||||||
|
new StaRuntime(),
|
||||||
|
new MxAccessComObjectFactory(),
|
||||||
|
new MxAccessBaseEventSink())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxAccessStaSession(
|
||||||
|
StaRuntime staRuntime,
|
||||||
|
IMxAccessComObjectFactory factory,
|
||||||
|
IMxAccessEventSink eventSink)
|
||||||
|
{
|
||||||
|
this.staRuntime = staRuntime ?? throw new ArgumentNullException(nameof(staRuntime));
|
||||||
|
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||||
|
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WorkerReady> StartAsync(
|
||||||
|
int workerProcessId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
staRuntime.Start();
|
||||||
|
|
||||||
|
return staRuntime.InvokeAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (session is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("MXAccess COM session has already been created.");
|
||||||
|
}
|
||||||
|
|
||||||
|
session = MxAccessSession.Create(factory, eventSink);
|
||||||
|
commandDispatcher = new StaCommandDispatcher(
|
||||||
|
staRuntime,
|
||||||
|
new MxAccessCommandExecutor(session));
|
||||||
|
|
||||||
|
return session.CreateWorkerReady(workerProcessId);
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> DispatchAsync(StaCommand command)
|
||||||
|
{
|
||||||
|
if (commandDispatcher is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("MXAccess COM session has not been started.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return commandDispatcher.DispatchAsync(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<RegisteredServerHandle>> GetRegisteredServerHandlesAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("MXAccess COM session has not been started.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return staRuntime.InvokeAsync(
|
||||||
|
() => session.HandleRegistry.ServerHandles,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commandDispatcher?.RequestShutdown();
|
||||||
|
|
||||||
|
if (session is not null)
|
||||||
|
{
|
||||||
|
staRuntime.InvokeAsync(() => session.Dispose()).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
staRuntime.Dispose();
|
||||||
|
disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
public sealed class RegisteredServerHandle
|
||||||
|
{
|
||||||
|
public RegisteredServerHandle(
|
||||||
|
int serverHandle,
|
||||||
|
string clientName)
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle;
|
||||||
|
ClientName = clientName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ServerHandle { get; }
|
||||||
|
|
||||||
|
public string ClientName { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.Sta;
|
||||||
|
|
||||||
|
public interface IStaCommandExecutor
|
||||||
|
{
|
||||||
|
MxCommandReply Execute(StaCommand command);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.Sta;
|
||||||
|
|
||||||
|
public sealed class StaCommand
|
||||||
|
{
|
||||||
|
public StaCommand(
|
||||||
|
string sessionId,
|
||||||
|
string correlationId,
|
||||||
|
MxCommand command,
|
||||||
|
Timestamp? enqueueTimestamp = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("STA command requires a session id.", nameof(sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(correlationId))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("STA command requires a correlation id.", nameof(correlationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionId = sessionId;
|
||||||
|
CorrelationId = correlationId;
|
||||||
|
Command = command ?? throw new ArgumentNullException(nameof(command));
|
||||||
|
EnqueueTimestamp = enqueueTimestamp ?? Timestamp.FromDateTime(DateTime.UtcNow);
|
||||||
|
CancellationToken = cancellationToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SessionId { get; }
|
||||||
|
|
||||||
|
public string CorrelationId { get; }
|
||||||
|
|
||||||
|
public MxCommand Command { get; }
|
||||||
|
|
||||||
|
public Timestamp EnqueueTimestamp { get; }
|
||||||
|
|
||||||
|
public CancellationToken CancellationToken { get; }
|
||||||
|
|
||||||
|
public MxCommandKind Kind => Command.Kind;
|
||||||
|
|
||||||
|
public string MethodName => Kind.ToString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Worker.Conversion;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.Sta;
|
||||||
|
|
||||||
|
public sealed class StaCommandDispatcher
|
||||||
|
{
|
||||||
|
private readonly HResultConverter hresultConverter;
|
||||||
|
private readonly IStaCommandExecutor commandExecutor;
|
||||||
|
private readonly Queue<QueuedStaCommand> commandQueue = new();
|
||||||
|
private readonly StaRuntime staRuntime;
|
||||||
|
private readonly object gate = new();
|
||||||
|
private bool drainActive;
|
||||||
|
private bool shutdownRequested;
|
||||||
|
private string currentCommandCorrelationId = string.Empty;
|
||||||
|
|
||||||
|
public StaCommandDispatcher(
|
||||||
|
StaRuntime staRuntime,
|
||||||
|
IStaCommandExecutor commandExecutor)
|
||||||
|
: this(staRuntime, commandExecutor, new HResultConverter())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public StaCommandDispatcher(
|
||||||
|
StaRuntime staRuntime,
|
||||||
|
IStaCommandExecutor commandExecutor,
|
||||||
|
HResultConverter hresultConverter)
|
||||||
|
{
|
||||||
|
this.staRuntime = staRuntime ?? throw new ArgumentNullException(nameof(staRuntime));
|
||||||
|
this.commandExecutor = commandExecutor ?? throw new ArgumentNullException(nameof(commandExecutor));
|
||||||
|
this.hresultConverter = hresultConverter ?? throw new ArgumentNullException(nameof(hresultConverter));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int PendingCommandCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return commandQueue.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CurrentCommandCorrelationId
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return currentCommandCorrelationId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> DispatchAsync(StaCommand command)
|
||||||
|
{
|
||||||
|
if (command is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(command));
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (shutdownRequested)
|
||||||
|
{
|
||||||
|
return Task.FromResult(CreateRejectedReply(
|
||||||
|
command,
|
||||||
|
ProtocolStatusCode.WorkerUnavailable,
|
||||||
|
"The STA command dispatcher is shutting down."));
|
||||||
|
}
|
||||||
|
|
||||||
|
QueuedStaCommand queuedCommand = new(command);
|
||||||
|
commandQueue.Enqueue(queuedCommand);
|
||||||
|
|
||||||
|
if (!drainActive)
|
||||||
|
{
|
||||||
|
drainActive = true;
|
||||||
|
_ = DrainAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return queuedCommand.Task;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RequestShutdown()
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
shutdownRequested = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PopulateHeartbeat(WorkerHeartbeat heartbeat)
|
||||||
|
{
|
||||||
|
if (heartbeat is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(heartbeat));
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
heartbeat.PendingCommandCount = (uint)commandQueue.Count;
|
||||||
|
heartbeat.CurrentCommandCorrelationId = currentCommandCorrelationId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DrainAsync()
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
QueuedStaCommand queuedCommand;
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (commandQueue.Count == 0)
|
||||||
|
{
|
||||||
|
drainActive = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queuedCommand = commandQueue.Dequeue();
|
||||||
|
}
|
||||||
|
|
||||||
|
await ExecuteQueuedCommandAsync(queuedCommand).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecuteQueuedCommandAsync(QueuedStaCommand queuedCommand)
|
||||||
|
{
|
||||||
|
StaCommand command = queuedCommand.Command;
|
||||||
|
if (command.CancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
queuedCommand.Complete(CreateRejectedReply(
|
||||||
|
command,
|
||||||
|
ProtocolStatusCode.Canceled,
|
||||||
|
"The STA command was canceled before execution."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetCurrentCommand(command.CorrelationId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await staRuntime
|
||||||
|
.InvokeAsync(() => commandExecutor.Execute(command))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
queuedCommand.Complete(NormalizeReply(command, reply));
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
queuedCommand.Complete(CreateExceptionReply(command, exception));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SetCurrentCommand(string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetCurrentCommand(string correlationId)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
currentCommandCorrelationId = correlationId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private MxCommandReply NormalizeReply(
|
||||||
|
StaCommand command,
|
||||||
|
MxCommandReply reply)
|
||||||
|
{
|
||||||
|
if (reply is null)
|
||||||
|
{
|
||||||
|
return CreateRejectedReply(
|
||||||
|
command,
|
||||||
|
ProtocolStatusCode.ProtocolViolation,
|
||||||
|
"STA command executor returned null.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(reply.SessionId))
|
||||||
|
{
|
||||||
|
reply.SessionId = command.SessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(reply.CorrelationId))
|
||||||
|
{
|
||||||
|
reply.CorrelationId = command.CorrelationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reply.Kind == MxCommandKind.Unspecified)
|
||||||
|
{
|
||||||
|
reply.Kind = command.Kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reply.ProtocolStatus is null)
|
||||||
|
{
|
||||||
|
reply.ProtocolStatus = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.Ok,
|
||||||
|
Message = "OK",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MxCommandReply CreateExceptionReply(
|
||||||
|
StaCommand command,
|
||||||
|
Exception exception)
|
||||||
|
{
|
||||||
|
HResultConversion conversion = hresultConverter.Convert(exception);
|
||||||
|
MxCommandReply reply = CreateBaseReply(command);
|
||||||
|
reply.ProtocolStatus = conversion.ProtocolStatus;
|
||||||
|
reply.Hresult = conversion.HResult;
|
||||||
|
reply.DiagnosticMessage = conversion.DiagnosticMessage;
|
||||||
|
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommandReply CreateRejectedReply(
|
||||||
|
StaCommand command,
|
||||||
|
ProtocolStatusCode statusCode,
|
||||||
|
string message)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = CreateBaseReply(command);
|
||||||
|
reply.ProtocolStatus = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = statusCode,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
reply.DiagnosticMessage = message;
|
||||||
|
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommandReply CreateBaseReply(StaCommand command)
|
||||||
|
{
|
||||||
|
return new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = command.SessionId,
|
||||||
|
CorrelationId = command.CorrelationId,
|
||||||
|
Kind = command.Kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class QueuedStaCommand
|
||||||
|
{
|
||||||
|
private readonly TaskCompletionSource<MxCommandReply> completion = new(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
public QueuedStaCommand(StaCommand command)
|
||||||
|
{
|
||||||
|
Command = command;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StaCommand Command { get; }
|
||||||
|
|
||||||
|
public Task<MxCommandReply> Task => completion.Task;
|
||||||
|
|
||||||
|
public void Complete(MxCommandReply reply)
|
||||||
|
{
|
||||||
|
completion.TrySetResult(reply);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user