Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8793011838 | |||
| b275eedb44 | |||
| a9ef6d10d4 | |||
| 0f17a1d1d9 | |||
| 160343aff4 | |||
| 8ef98b8beb | |||
| f049d3e603 | |||
| ee88f9d647 | |||
| 6e34efd1a5 | |||
| 01d6c33156 | |||
| ec4e2f687e | |||
| f7929cc12f | |||
| d890eff862 | |||
| 9dcd4baff2 | |||
| 7a0743496f | |||
| bcfbd1cfc8 | |||
| 8e3b0c1c4a | |||
| bd4be85f26 | |||
| 7331c6157a | |||
| cbc317e3e7 | |||
| 7242cf772b | |||
| 7d67313a7d | |||
| 044b16c5db | |||
| 1f92078777 | |||
| 4a3560c7ee | |||
| 108a3d3f8a | |||
| 95e71cd819 |
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using MxGateway.Client;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Cli;
|
||||||
|
|
||||||
|
public static class MxGatewayClientCli
|
||||||
|
{
|
||||||
|
public static int Run(
|
||||||
|
string[] args,
|
||||||
|
TextWriter standardOutput,
|
||||||
|
TextWriter standardError)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(args);
|
||||||
|
ArgumentNullException.ThrowIfNull(standardOutput);
|
||||||
|
ArgumentNullException.ThrowIfNull(standardError);
|
||||||
|
|
||||||
|
if (args.Length is 0 || IsHelp(args[0]))
|
||||||
|
{
|
||||||
|
WriteUsage(standardOutput);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(args[0], "version", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
standardOutput.WriteLine(
|
||||||
|
$"gateway-protocol={MxGatewayClientContractInfo.GatewayProtocolVersion}");
|
||||||
|
standardOutput.WriteLine(
|
||||||
|
$"worker-protocol={MxGatewayClientContractInfo.WorkerProtocolVersion}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
standardError.WriteLine($"Unknown command '{args[0]}'.");
|
||||||
|
WriteUsage(standardError);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsHelp(string value)
|
||||||
|
{
|
||||||
|
return string.Equals(value, "-h", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(value, "--help", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(value, "help", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUsage(TextWriter writer)
|
||||||
|
{
|
||||||
|
writer.WriteLine("mxgw-dotnet version");
|
||||||
|
writer.WriteLine("mxgw-dotnet --help");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using MxGateway.Client.Cli;
|
||||||
|
|
||||||
|
return MxGatewayClientCli.Run(args, Console.Out, Console.Error);
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMxGatewayClientTransport
|
||||||
|
{
|
||||||
|
private readonly Queue<MxCommandReply> _invokeReplies = new();
|
||||||
|
private readonly List<MxEvent> _events = [];
|
||||||
|
|
||||||
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
|
public MxAccessGateway.MxAccessGatewayClient? RawClient => null;
|
||||||
|
|
||||||
|
public List<(OpenSessionRequest Request, CallOptions CallOptions)> OpenSessionCalls { get; } = [];
|
||||||
|
|
||||||
|
public List<(CloseSessionRequest Request, CallOptions CallOptions)> CloseSessionCalls { get; } = [];
|
||||||
|
|
||||||
|
public List<(MxCommandRequest Request, CallOptions CallOptions)> InvokeCalls { get; } = [];
|
||||||
|
|
||||||
|
public List<(StreamEventsRequest Request, CallOptions CallOptions)> StreamEventsCalls { get; } = [];
|
||||||
|
|
||||||
|
public OpenSessionReply OpenSessionReply { get; set; } = new()
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
BackendName = "mxaccess-worker",
|
||||||
|
GatewayProtocolVersion = 1,
|
||||||
|
WorkerProtocolVersion = 1,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
};
|
||||||
|
|
||||||
|
public CloseSessionReply CloseSessionReply { get; set; } = new()
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
FinalState = SessionState.Closed,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
};
|
||||||
|
|
||||||
|
public Task<OpenSessionReply> OpenSessionAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
OpenSessionCalls.Add((request, callOptions));
|
||||||
|
return Task.FromResult(OpenSessionReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<CloseSessionReply> CloseSessionAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
CloseSessionCalls.Add((request, callOptions));
|
||||||
|
return Task.FromResult(CloseSessionReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
InvokeCalls.Add((request, callOptions));
|
||||||
|
return Task.FromResult(_invokeReplies.Dequeue());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
StreamEventsCalls.Add((request, callOptions));
|
||||||
|
|
||||||
|
foreach (MxEvent gatewayEvent in _events)
|
||||||
|
{
|
||||||
|
callOptions.CancellationToken.ThrowIfCancellationRequested();
|
||||||
|
await Task.Yield();
|
||||||
|
yield return gatewayEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddInvokeReply(MxCommandReply reply)
|
||||||
|
{
|
||||||
|
_invokeReplies.Enqueue(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddEvent(MxEvent gatewayEvent)
|
||||||
|
{
|
||||||
|
_events.Add(gatewayEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" />
|
||||||
|
<ProjectReference Include="..\MxGateway.Client.Cli\MxGateway.Client.Cli.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using MxGateway.Client.Cli;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxGatewayClientCliTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Run_Version_PrintsCompiledProtocolVersions()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
using var error = new StringWriter();
|
||||||
|
|
||||||
|
var exitCode = MxGatewayClientCli.Run(["version"], output, error);
|
||||||
|
|
||||||
|
Assert.Equal(0, exitCode);
|
||||||
|
Assert.Contains("gateway-protocol=1", output.ToString());
|
||||||
|
Assert.Contains("worker-protocol=1", output.ToString());
|
||||||
|
Assert.Equal(string.Empty, error.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using MxGateway.Contracts;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxGatewayClientContractInfoTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GatewayProtocolVersion_MatchesSharedContract()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
GatewayContractInfo.GatewayProtocolVersion,
|
||||||
|
MxGatewayClientContractInfo.GatewayProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WorkerProtocolVersion_MatchesSharedContract()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
GatewayContractInfo.WorkerProtocolVersion,
|
||||||
|
MxGatewayClientContractInfo.WorkerProtocolVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxGatewayClientOptionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WithAbsoluteEndpointAndApiKey_Succeeds()
|
||||||
|
{
|
||||||
|
var options = new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = "test-api-key",
|
||||||
|
};
|
||||||
|
|
||||||
|
options.Validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_WithEmptyApiKey_Throws()
|
||||||
|
{
|
||||||
|
var options = new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = "",
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentException>(options.Validate);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxGatewayClientSessionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
await client.OpenSessionRawAsync(new OpenSessionRequest(), cancellation.Token);
|
||||||
|
|
||||||
|
var call = Assert.Single(transport.OpenSessionCalls);
|
||||||
|
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
|
||||||
|
Assert.Equal(cancellation.Token, call.CallOptions.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.OpenSessionReply.WorkerProcessId = 1234;
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
Assert.Equal("session-fixture", session.SessionId);
|
||||||
|
Assert.Same(transport.OpenSessionReply, session.OpenSessionReply);
|
||||||
|
Assert.Equal(1234, session.OpenSessionReply.WorkerProcessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.Register,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
Register = new RegisterReply { ServerHandle = 12 },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
int serverHandle = await session.RegisterAsync("fixture-client");
|
||||||
|
|
||||||
|
Assert.Equal(12, serverHandle);
|
||||||
|
var call = Assert.Single(transport.InvokeCalls);
|
||||||
|
Assert.Equal("session-fixture", call.Request.SessionId);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(call.Request.ClientCorrelationId));
|
||||||
|
Assert.Equal(MxCommandKind.Register, call.Request.Command.Kind);
|
||||||
|
Assert.Equal("fixture-client", call.Request.Command.Register.ClientName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddItem2Async_BuildsAddItem2CommandWithContext()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.AddItem2,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
AddItem2 = new AddItem2Reply { ItemHandle = 34 },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
int itemHandle = await session.AddItem2Async(12, "Area001.Pump001.Speed", "runtime");
|
||||||
|
|
||||||
|
Assert.Equal(34, itemHandle);
|
||||||
|
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||||
|
Assert.Equal(MxCommandKind.AddItem2, request.Command.Kind);
|
||||||
|
Assert.Equal(12, request.Command.AddItem2.ServerHandle);
|
||||||
|
Assert.Equal("Area001.Pump001.Speed", request.Command.AddItem2.ItemDefinition);
|
||||||
|
Assert.Equal("runtime", request.Command.AddItem2.ItemContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteRawAsync_BuildsWriteCommandWithRawValue()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.Write,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
MxValue value = new()
|
||||||
|
{
|
||||||
|
DataType = MxDataType.Integer,
|
||||||
|
VariantType = "VT_I4",
|
||||||
|
Int32Value = 123,
|
||||||
|
};
|
||||||
|
|
||||||
|
MxCommandReply reply = await session.WriteRawAsync(12, 34, value, 56);
|
||||||
|
|
||||||
|
Assert.Equal(MxCommandKind.Write, reply.Kind);
|
||||||
|
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||||
|
Assert.Equal(MxCommandKind.Write, request.Command.Kind);
|
||||||
|
Assert.Equal(12, request.Command.Write.ServerHandle);
|
||||||
|
Assert.Equal(34, request.Command.Write.ItemHandle);
|
||||||
|
Assert.Same(value, request.Command.Write.Value);
|
||||||
|
Assert.Equal(56, request.Command.Write.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddEvent(new MxEvent
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Family = MxEventFamily.OnDataChange,
|
||||||
|
WorkerSequence = 1,
|
||||||
|
});
|
||||||
|
transport.AddEvent(new MxEvent
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Family = MxEventFamily.OnWriteComplete,
|
||||||
|
WorkerSequence = 2,
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
List<ulong> sequences = [];
|
||||||
|
|
||||||
|
await foreach (MxEvent gatewayEvent in session.StreamEventsAsync(afterWorkerSequence: 0))
|
||||||
|
{
|
||||||
|
sequences.Add(gatewayEvent.WorkerSequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal([1UL, 2UL], sequences);
|
||||||
|
StreamEventsRequest request = Assert.Single(transport.StreamEventsCalls).Request;
|
||||||
|
Assert.Equal("session-fixture", request.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CloseAsync_IsExplicitAndIdempotent()
|
||||||
|
{
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
CloseSessionReply first = await session.CloseAsync();
|
||||||
|
CloseSessionReply second = await session.CloseAsync();
|
||||||
|
|
||||||
|
Assert.Same(first, second);
|
||||||
|
var call = Assert.Single(transport.CloseSessionCalls);
|
||||||
|
Assert.Equal("session-fixture", call.Request.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeHelpers_PassCancellationTokenToTransport()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
FakeGatewayTransport transport = CreateTransport();
|
||||||
|
transport.AddInvokeReply(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-fixture",
|
||||||
|
Kind = MxCommandKind.Advise,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
});
|
||||||
|
await using MxGatewayClient client = CreateClient(transport);
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
|
||||||
|
await session.AdviseAsync(12, 34, cancellation.Token);
|
||||||
|
|
||||||
|
Assert.Equal(cancellation.Token, Assert.Single(transport.InvokeCalls).CallOptions.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxGatewayClient CreateClient(FakeGatewayTransport transport)
|
||||||
|
{
|
||||||
|
return new MxGatewayClient(transport.Options, transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FakeGatewayTransport CreateTransport()
|
||||||
|
{
|
||||||
|
return new FakeGatewayTransport(new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = "test-api-key",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace MxGateway.Client.Tests;
|
||||||
|
|
||||||
|
public sealed class MxGatewayGeneratedContractTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory()
|
||||||
|
{
|
||||||
|
var options = new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = "test-api-key",
|
||||||
|
};
|
||||||
|
|
||||||
|
await using var client = MxGatewayClient.Create(options);
|
||||||
|
|
||||||
|
Assert.NotNull(client.RawClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client", "MxGateway.Client\MxGateway.Client.csproj", "{7CF9ED88-1F32-4040-BEB1-D0902E304C70}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Contracts", "..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj", "{9AB807A8-0469-40F7-A000-D240F36B6E5D}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Cli", "MxGateway.Client.Cli\MxGateway.Client.Cli.csproj", "{EB061E77-2475-4322-9257-3F2456DD141C}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Tests", "MxGateway.Client.Tests\MxGateway.Client.Tests.csproj", "{B77B5A8E-0C53-4419-9BCD-227C9753A074}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
internal sealed class GrpcMxGatewayClientTransport(
|
||||||
|
MxGatewayClientOptions options,
|
||||||
|
MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport
|
||||||
|
{
|
||||||
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
|
public MxAccessGateway.MxAccessGatewayClient RawClient { get; } = rawClient;
|
||||||
|
|
||||||
|
MxAccessGateway.MxAccessGatewayClient? IMxGatewayClientTransport.RawClient => RawClient;
|
||||||
|
|
||||||
|
public async Task<OpenSessionReply> OpenSessionAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
return await RawClient.OpenSessionAsync(request, callOptions)
|
||||||
|
.ResponseAsync
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CloseSessionReply> CloseSessionAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
return await RawClient.CloseSessionAsync(request, callOptions)
|
||||||
|
.ResponseAsync
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
return await RawClient.InvokeAsync(request, callOptions)
|
||||||
|
.ResponseAsync
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CallOptions callOptions,
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
|
||||||
|
? cancellationToken
|
||||||
|
: callOptions.CancellationToken;
|
||||||
|
|
||||||
|
using AsyncServerStreamingCall<MxEvent> call = RawClient.StreamEvents(request, callOptions);
|
||||||
|
|
||||||
|
await foreach (MxEvent gatewayEvent in call.ResponseStream
|
||||||
|
.ReadAllAsync(effectiveCancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
yield return gatewayEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IAsyncEnumerable<MxEvent> IMxGatewayClientTransport.StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CallOptions callOptions)
|
||||||
|
{
|
||||||
|
return StreamEventsAsync(request, callOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
internal interface IMxGatewayClientTransport
|
||||||
|
{
|
||||||
|
MxGatewayClientOptions Options { get; }
|
||||||
|
|
||||||
|
MxAccessGateway.MxAccessGatewayClient? RawClient { get; }
|
||||||
|
|
||||||
|
Task<OpenSessionReply> OpenSessionAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
|
||||||
|
Task<CloseSessionReply> CloseSessionAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
|
||||||
|
Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
|
||||||
|
IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CallOptions callOptions);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using Grpc.Net.Client;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides the .NET client entry point for the public MXAccess Gateway gRPC API.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MxGatewayClient : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly GrpcChannel _channel;
|
||||||
|
private readonly IMxGatewayClientTransport _transport;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
internal MxGatewayClient(
|
||||||
|
MxGatewayClientOptions options,
|
||||||
|
IMxGatewayClientTransport transport)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
options.Validate();
|
||||||
|
|
||||||
|
Options = options;
|
||||||
|
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||||
|
_channel = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MxGatewayClient(
|
||||||
|
GrpcChannel channel,
|
||||||
|
IMxGatewayClientTransport transport)
|
||||||
|
{
|
||||||
|
_channel = channel;
|
||||||
|
_transport = transport;
|
||||||
|
Options = transport.Options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxGatewayClientOptions Options { get; }
|
||||||
|
|
||||||
|
public MxAccessGateway.MxAccessGatewayClient RawClient =>
|
||||||
|
_transport.RawClient
|
||||||
|
?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance.");
|
||||||
|
|
||||||
|
public static MxGatewayClient Create(MxGatewayClientOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
options.Validate();
|
||||||
|
|
||||||
|
var channel = GrpcChannel.ForAddress(
|
||||||
|
options.Endpoint,
|
||||||
|
new GrpcChannelOptions
|
||||||
|
{
|
||||||
|
LoggerFactory = options.LoggerFactory,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new MxGatewayClient(
|
||||||
|
channel,
|
||||||
|
new GrpcMxGatewayClientTransport(
|
||||||
|
options,
|
||||||
|
new MxAccessGateway.MxAccessGatewayClient(channel)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MxGatewaySession> OpenSessionAsync(
|
||||||
|
OpenSessionRequest? request = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
OpenSessionReply reply = await OpenSessionRawAsync(
|
||||||
|
request ?? new OpenSessionRequest(),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new MxGatewaySession(this, reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<OpenSessionReply> OpenSessionRawAsync(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return _transport.OpenSessionAsync(request, CreateCallOptions(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<CloseSessionReply> CloseSessionRawAsync(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return _transport.CloseSessionAsync(request, CreateCallOptions(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return _transport.InvokeAsync(request, CreateCallOptions(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
|
||||||
|
return _transport.StreamEventsAsync(request, CreateCallOptions(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_channel?.Dispose();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal CallOptions CreateCallOptions(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Metadata headers = new()
|
||||||
|
{
|
||||||
|
{ "authorization", $"Bearer {Options.ApiKey}" },
|
||||||
|
};
|
||||||
|
|
||||||
|
return new CallOptions(
|
||||||
|
headers,
|
||||||
|
DateTime.UtcNow.Add(Options.DefaultCallTimeout),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using MxGateway.Contracts;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exposes the protocol versions compiled into this client package.
|
||||||
|
/// </summary>
|
||||||
|
public static class MxGatewayClientContractInfo
|
||||||
|
{
|
||||||
|
public const uint GatewayProtocolVersion =
|
||||||
|
GatewayContractInfo.GatewayProtocolVersion;
|
||||||
|
|
||||||
|
public const uint WorkerProtocolVersion =
|
||||||
|
GatewayContractInfo.WorkerProtocolVersion;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the gRPC channel used by the .NET MXAccess Gateway client.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
public required Uri Endpoint { get; init; }
|
||||||
|
|
||||||
|
public required string ApiKey { get; init; }
|
||||||
|
|
||||||
|
public bool UseTls { get; init; }
|
||||||
|
|
||||||
|
public string? CaCertificatePath { get; init; }
|
||||||
|
|
||||||
|
public string? ServerNameOverride { get; init; }
|
||||||
|
|
||||||
|
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
public ILoggerFactory? LoggerFactory { get; init; }
|
||||||
|
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(Endpoint);
|
||||||
|
|
||||||
|
if (!Endpoint.IsAbsoluteUri)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"The gateway endpoint must be an absolute URI.",
|
||||||
|
nameof(Endpoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(ApiKey))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
"The gateway API key must not be empty.",
|
||||||
|
nameof(ApiKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ConnectTimeout <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(ConnectTimeout),
|
||||||
|
"The connect timeout must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DefaultCallTimeout <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(DefaultCallTimeout),
|
||||||
|
"The default call timeout must be greater than zero.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents one gateway-backed MXAccess session.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MxGatewaySession : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly MxGatewayClient _client;
|
||||||
|
private readonly SemaphoreSlim _closeLock = new(1, 1);
|
||||||
|
private CloseSessionReply? _closeReply;
|
||||||
|
|
||||||
|
internal MxGatewaySession(
|
||||||
|
MxGatewayClient client,
|
||||||
|
OpenSessionReply openSessionReply)
|
||||||
|
{
|
||||||
|
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||||||
|
OpenSessionReply = openSessionReply ?? throw new ArgumentNullException(nameof(openSessionReply));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SessionId => OpenSessionReply.SessionId;
|
||||||
|
|
||||||
|
public OpenSessionReply OpenSessionReply { get; }
|
||||||
|
|
||||||
|
public async Task<CloseSessionReply> CloseAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_closeReply is not null)
|
||||||
|
{
|
||||||
|
return _closeReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_closeReply is not null)
|
||||||
|
{
|
||||||
|
return _closeReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
_closeReply = await _client.CloseSessionRawAsync(
|
||||||
|
new CloseSessionRequest { SessionId = SessionId },
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return _closeReply;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_closeLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> RegisterAsync(
|
||||||
|
string clientName,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await RegisterRawAsync(clientName, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return reply.Register?.ServerHandle ?? reply.ReturnValue.Int32Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> RegisterRawAsync(
|
||||||
|
string clientName,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(clientName);
|
||||||
|
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Register,
|
||||||
|
Register = new RegisterCommand { ClientName = clientName },
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> AddItemAsync(
|
||||||
|
int serverHandle,
|
||||||
|
string itemDefinition,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await AddItemRawAsync(
|
||||||
|
serverHandle,
|
||||||
|
itemDefinition,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return reply.AddItem?.ItemHandle ?? reply.ReturnValue.Int32Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> AddItemRawAsync(
|
||||||
|
int serverHandle,
|
||||||
|
string itemDefinition,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(itemDefinition);
|
||||||
|
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItem,
|
||||||
|
AddItem = new AddItemCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemDefinition = itemDefinition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> AddItem2Async(
|
||||||
|
int serverHandle,
|
||||||
|
string itemDefinition,
|
||||||
|
string itemContext,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await AddItem2RawAsync(
|
||||||
|
serverHandle,
|
||||||
|
itemDefinition,
|
||||||
|
itemContext,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return reply.AddItem2?.ItemHandle ?? reply.ReturnValue.Int32Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> AddItem2RawAsync(
|
||||||
|
int serverHandle,
|
||||||
|
string itemDefinition,
|
||||||
|
string itemContext,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(itemDefinition);
|
||||||
|
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItem2,
|
||||||
|
AddItem2 = new AddItem2Command
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemDefinition = itemDefinition,
|
||||||
|
ItemContext = itemContext ?? string.Empty,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AdviseAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await AdviseRawAsync(serverHandle, itemHandle, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> AdviseRawAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Advise,
|
||||||
|
Advise = new AdviseCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemHandle = itemHandle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WriteAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
MxValue value,
|
||||||
|
int userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await WriteRawAsync(serverHandle, itemHandle, value, userId, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> WriteRawAsync(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle,
|
||||||
|
MxValue value,
|
||||||
|
int userId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
|
|
||||||
|
return InvokeCommandAsync(
|
||||||
|
new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Write,
|
||||||
|
Write = new WriteCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemHandle = itemHandle,
|
||||||
|
Value = value,
|
||||||
|
UserId = userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> InvokeAsync(
|
||||||
|
MxCommandRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
return _client.InvokeAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
ulong afterWorkerSequence = 0,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return _client.StreamEventsAsync(
|
||||||
|
new StreamEventsRequest
|
||||||
|
{
|
||||||
|
SessionId = SessionId,
|
||||||
|
AfterWorkerSequence = afterWorkerSequence,
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await CloseAsync().ConfigureAwait(false);
|
||||||
|
_closeLock.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<MxCommandReply> InvokeCommandAsync(
|
||||||
|
MxCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _client.InvokeAsync(
|
||||||
|
new MxCommandRequest
|
||||||
|
{
|
||||||
|
SessionId = SessionId,
|
||||||
|
ClientCorrelationId = Guid.NewGuid().ToString("N"),
|
||||||
|
Command = command,
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("MxGateway.Client.Tests")]
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# .NET Client Projects
|
||||||
|
|
||||||
|
The .NET client workspace contains the MXAccess Gateway client library, test
|
||||||
|
CLI, and unit tests.
|
||||||
|
|
||||||
|
## Projects
|
||||||
|
|
||||||
|
| Project | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `MxGateway.Client` | .NET 10 library entry point, raw gRPC calls, and session helpers. |
|
||||||
|
| `MxGateway.Client.Cli` | Test CLI for smoke and diagnostic commands. |
|
||||||
|
| `MxGateway.Client.Tests` | Unit tests for client options, generated contract wiring, auth metadata, session helpers, cancellation, and event streaming. |
|
||||||
|
|
||||||
|
The projects reference `src/MxGateway.Contracts/MxGateway.Contracts.csproj` so
|
||||||
|
the client compiles against the same generated protobuf and gRPC types as the
|
||||||
|
gateway. `clients/dotnet/generated` remains reserved for generator output if a
|
||||||
|
future client build switches to client-local `Grpc.Tools` generation.
|
||||||
|
|
||||||
|
## Build And Test
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build clients/dotnet/MxGateway.Client.sln
|
||||||
|
dotnet test clients/dotnet/MxGateway.Client.sln --no-build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Usage
|
||||||
|
|
||||||
|
`MxGatewayClient` opens a gRPC channel to the gateway and attaches the API key
|
||||||
|
to every unary and streaming call as `authorization: Bearer <api-key>`.
|
||||||
|
Cancellation tokens passed to the public methods flow to the generated gRPC
|
||||||
|
call. Client-side cancellation stops waiting for the gateway response; it does
|
||||||
|
not abort an MXAccess COM call that is already executing inside a worker.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await using MxGatewayClient client = MxGatewayClient.Create(
|
||||||
|
new MxGatewayClientOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:5000"),
|
||||||
|
ApiKey = apiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
MxGatewaySession session = await client.OpenSessionAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int serverHandle = await session.RegisterAsync("sample-client");
|
||||||
|
int itemHandle = await session.AddItemAsync(
|
||||||
|
serverHandle,
|
||||||
|
"Area001.Pump001.Speed");
|
||||||
|
|
||||||
|
await session.AdviseAsync(serverHandle, itemHandle);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await session.CloseAsync();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `OpenSessionRawAsync`, `CloseSessionRawAsync`, `InvokeAsync`, and
|
||||||
|
`StreamEventsAsync` when tests or parity tools need direct generated protobuf
|
||||||
|
messages. `MxGatewaySession.OpenSessionReply` keeps the raw session-open reply
|
||||||
|
available, and command helpers have `*RawAsync` variants when callers need the
|
||||||
|
complete `MxCommandReply`.
|
||||||
|
|
||||||
|
`MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return
|
||||||
|
the first `CloseSessionReply` instead of sending another close request.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Go Client
|
||||||
|
|
||||||
|
The Go client module contains the generated MXAccess Gateway protobuf bindings,
|
||||||
|
a small handwritten `mxgateway` package, and the `mxgw-go` test CLI scaffold.
|
||||||
|
The module uses the shared proto inputs documented in
|
||||||
|
`../../docs/client-proto-generation.md` so gateway and client contracts stay in
|
||||||
|
sync.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
clients/go/
|
||||||
|
go.mod
|
||||||
|
generate-proto.ps1
|
||||||
|
internal/generated/
|
||||||
|
mxgateway/
|
||||||
|
cmd/mxgw-go/
|
||||||
|
```
|
||||||
|
|
||||||
|
`internal/generated` contains code produced by `protoc`, `protoc-gen-go`, and
|
||||||
|
`protoc-gen-go-grpc`. Do not edit generated files by hand.
|
||||||
|
|
||||||
|
## Regenerating Protobuf Bindings
|
||||||
|
|
||||||
|
Run generation after the shared `.proto` files or the Go output path changes:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./generate-proto.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
The script uses the tool paths recorded in `../../docs/toolchain-links.md`.
|
||||||
|
|
||||||
|
## Build And Test
|
||||||
|
|
||||||
|
Run the Go module checks from `clients/go`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go test ./...
|
||||||
|
go build ./...
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
The tests parse the shared JSON fixtures, exercise value and status conversion,
|
||||||
|
use `bufconn` for fake gateway auth and streaming behavior, and cover CLI JSON
|
||||||
|
redaction.
|
||||||
|
|
||||||
|
## Client API
|
||||||
|
|
||||||
|
Use `mxgateway.Dial` with `mxgateway.Options` to configure plaintext or TLS
|
||||||
|
transport, API-key metadata, dial timeout, and per-call timeout:
|
||||||
|
|
||||||
|
```go
|
||||||
|
client, err := mxgateway.Dial(ctx, mxgateway.Options{
|
||||||
|
Endpoint: "localhost:5000",
|
||||||
|
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
|
||||||
|
Plaintext: true,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`Client.OpenSession` returns a `Session` with helpers for `Register`,
|
||||||
|
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Raw protobuf
|
||||||
|
messages remain available through the `mxgateway` package aliases and the
|
||||||
|
`Raw` helper methods. Typed errors support `errors.As` for `GatewayError`,
|
||||||
|
`CommandError`, and `MxAccessError`; command errors preserve the raw reply.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
The `mxgw-go` CLI emits JSON with redacted API keys for commands that connect to
|
||||||
|
the gateway:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go run ./cmd/mxgw-go version -json
|
||||||
|
go run ./cmd/mxgw-go open-session -endpoint localhost:5000 -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go register -session-id <id> -client-name mxgw-go -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go add-item -session-id <id> -server-handle 1 -item Area001.Tag.Value -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go advise -session-id <id> -server-handle 1 -item-handle 1 -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go write -session-id <id> -server-handle 1 -item-handle 1 -type int32 -value 123 -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go stream-events -session-id <id> -plaintext -json
|
||||||
|
go run ./cmd/mxgw-go smoke -item Area001.Tag.Value -plaintext -json
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `-api-key-env MXGATEWAY_API_KEY` or `-api-key <key>` when authentication is
|
||||||
|
enabled. CLI output redacts the key value and never writes the raw secret.
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
"google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
type versionOutput struct {
|
||||||
|
ClientVersion string `json:"clientVersion"`
|
||||||
|
GatewayProtocolVersion uint32 `json:"gatewayProtocolVersion"`
|
||||||
|
WorkerProtocolVersion uint32 `json:"workerProtocolVersion"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type commonOptions struct {
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
APIKey string `json:"apiKey"`
|
||||||
|
APIKeyEnv string `json:"apiKeyEnv,omitempty"`
|
||||||
|
Plaintext bool `json:"plaintext"`
|
||||||
|
CACertFile string `json:"caCertFile,omitempty"`
|
||||||
|
ServerName string `json:"serverNameOverride,omitempty"`
|
||||||
|
CallTimeout string `json:"callTimeout,omitempty"`
|
||||||
|
|
||||||
|
apiKeyValue string
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type openSessionOutput struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Options commonOptions `json:"options"`
|
||||||
|
Reply json.RawMessage `json:"reply"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type commandReplyOutput struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Options commonOptions `json:"options"`
|
||||||
|
Reply json.RawMessage `json:"reply"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := runWithIO(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args []string) error {
|
||||||
|
return runWithIO(context.Background(), args, os.Stdout, os.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
writeUsage(stderr)
|
||||||
|
return errors.New("missing command")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "version":
|
||||||
|
return runVersion(args[1:], stdout, stderr)
|
||||||
|
case "open-session":
|
||||||
|
return runOpenSession(ctx, args[1:], stdout, stderr)
|
||||||
|
case "close-session":
|
||||||
|
return runCloseSession(ctx, args[1:], stdout, stderr)
|
||||||
|
case "register":
|
||||||
|
return runRegister(ctx, args[1:], stdout, stderr)
|
||||||
|
case "add-item":
|
||||||
|
return runAddItem(ctx, args[1:], stdout, stderr)
|
||||||
|
case "advise":
|
||||||
|
return runAdvise(ctx, args[1:], stdout, stderr)
|
||||||
|
case "write":
|
||||||
|
return runWrite(ctx, args[1:], stdout, stderr)
|
||||||
|
case "stream-events":
|
||||||
|
return runStreamEvents(ctx, args[1:], stdout, stderr)
|
||||||
|
case "smoke":
|
||||||
|
return runSmoke(ctx, args[1:], stdout, stderr)
|
||||||
|
default:
|
||||||
|
writeUsage(stderr)
|
||||||
|
return fmt.Errorf("unknown command %q", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runVersion(args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("version", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
output := versionOutput{
|
||||||
|
ClientVersion: mxgateway.ClientVersion,
|
||||||
|
GatewayProtocolVersion: mxgateway.GatewayProtocolVersion,
|
||||||
|
WorkerProtocolVersion: mxgateway.WorkerProtocolVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
if *jsonOutput {
|
||||||
|
return writeJSON(stdout, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(stdout, "mxgw-go %s\n", output.ClientVersion)
|
||||||
|
fmt.Fprintf(stdout, "gateway protocol %d\n", output.GatewayProtocolVersion)
|
||||||
|
fmt.Fprintf(stdout, "worker protocol %d\n", output.WorkerProtocolVersion)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runOpenSession(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("open-session", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
clientName := flags.String("client-session-name", "", "client session name")
|
||||||
|
backend := flags.String("backend", "", "requested backend")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
reply, err := client.OpenSessionRaw(ctx, (&mxgateway.OpenSessionOptions{
|
||||||
|
RequestedBackend: *backend,
|
||||||
|
ClientSessionName: *clientName,
|
||||||
|
}).Request())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if *jsonOutput {
|
||||||
|
return writeJSON(stdout, openSessionOutput{
|
||||||
|
Command: "open-session",
|
||||||
|
Options: options,
|
||||||
|
Reply: mustMarshalProto(reply),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(stdout, reply.GetSessionId())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCloseSession(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("close-session", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
sessionID := flags.String("session-id", "", "gateway session id")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *sessionID == "" {
|
||||||
|
return errors.New("session-id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
reply, err := client.CloseSessionRaw(ctx, &mxgateway.CloseSessionRequest{SessionId: *sessionID})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *jsonOutput {
|
||||||
|
return writeJSON(stdout, commandReplyOutput{
|
||||||
|
Command: "close-session",
|
||||||
|
Options: options,
|
||||||
|
Reply: mustMarshalProto(reply),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(stdout, reply.GetFinalState())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRegister(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("register", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
sessionID := flags.String("session-id", "", "gateway session id")
|
||||||
|
clientName := flags.String("client-name", "", "MXAccess client name")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *sessionID == "" || *clientName == "" {
|
||||||
|
return errors.New("session-id and client-name are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
|
reply, err := session.RegisterRaw(ctx, *clientName)
|
||||||
|
return writeCommandOutput(stdout, *jsonOutput, "register", options, reply, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAddItem(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("add-item", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
sessionID := flags.String("session-id", "", "gateway session id")
|
||||||
|
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
|
||||||
|
item := flags.String("item", "", "item definition")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *sessionID == "" || *item == "" {
|
||||||
|
return errors.New("session-id and item are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
|
reply, err := session.AddItemRaw(ctx, int32(*serverHandle), *item)
|
||||||
|
return writeCommandOutput(stdout, *jsonOutput, "add-item", options, reply, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAdvise(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("advise", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
sessionID := flags.String("session-id", "", "gateway session id")
|
||||||
|
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
|
||||||
|
itemHandle := flags.Int("item-handle", 0, "MXAccess item handle")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *sessionID == "" {
|
||||||
|
return errors.New("session-id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
|
reply, err := session.AdviseRaw(ctx, int32(*serverHandle), int32(*itemHandle))
|
||||||
|
return writeCommandOutput(stdout, *jsonOutput, "advise", options, reply, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWrite(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("write", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
sessionID := flags.String("session-id", "", "gateway session id")
|
||||||
|
serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
|
||||||
|
itemHandle := flags.Int("item-handle", 0, "MXAccess item handle")
|
||||||
|
valueType := flags.String("type", "string", "value type: bool, int32, int64, float, double, string")
|
||||||
|
valueText := flags.String("value", "", "value text")
|
||||||
|
userID := flags.Int("user-id", 0, "MXAccess user id")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *sessionID == "" {
|
||||||
|
return errors.New("session-id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := parseValue(*valueType, *valueText)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
|
reply, err := session.WriteRaw(ctx, int32(*serverHandle), int32(*itemHandle), value, int32(*userID))
|
||||||
|
return writeCommandOutput(stdout, *jsonOutput, "write", options, reply, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStreamEvents(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("stream-events", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
sessionID := flags.String("session-id", "", "gateway session id")
|
||||||
|
after := flags.Uint64("after-worker-sequence", 0, "first worker sequence to read after")
|
||||||
|
limit := flags.Int("limit", 0, "maximum events to read; 0 means unbounded")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *sessionID == "" {
|
||||||
|
return errors.New("session-id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, _, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||||
|
streamCtx, cancelStream := context.WithCancel(ctx)
|
||||||
|
defer cancelStream()
|
||||||
|
events, err := session.EventsAfter(streamCtx, *after)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for result := range events {
|
||||||
|
if result.Err != nil {
|
||||||
|
return result.Err
|
||||||
|
}
|
||||||
|
if *jsonOutput {
|
||||||
|
fmt.Fprintln(stdout, string(mustMarshalProto(result.Event)))
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(stdout, "%d %s\n", result.Event.GetWorkerSequence(), result.Event.GetFamily())
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
if *limit > 0 && count >= *limit {
|
||||||
|
cancelStream()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSmoke(ctx context.Context, args []string, stdout, stderr io.Writer) error {
|
||||||
|
flags := flag.NewFlagSet("smoke", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
common := bindCommonFlags(flags)
|
||||||
|
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||||
|
clientName := flags.String("client-name", "mxgw-go-smoke", "MXAccess client name")
|
||||||
|
item := flags.String("item", "", "item definition")
|
||||||
|
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *item == "" {
|
||||||
|
return errors.New("item is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, options, err := dialForCommand(ctx, common)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
session, err := client.OpenSession(ctx, mxgateway.OpenSessionOptions{ClientSessionName: *clientName})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer session.Close(context.Background())
|
||||||
|
|
||||||
|
serverHandle, err := session.Register(ctx, *clientName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
itemHandle, err := session.AddItem(ctx, serverHandle, *item)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := session.Advise(ctx, serverHandle, itemHandle); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
output := map[string]any{
|
||||||
|
"command": "smoke",
|
||||||
|
"options": options,
|
||||||
|
"sessionId": session.ID(),
|
||||||
|
"serverHandle": serverHandle,
|
||||||
|
"itemHandle": itemHandle,
|
||||||
|
}
|
||||||
|
if *jsonOutput {
|
||||||
|
return writeJSON(stdout, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(stdout, "session=%s server=%d item=%d\n", session.ID(), serverHandle, itemHandle)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindCommonFlags(flags *flag.FlagSet) *commonOptions {
|
||||||
|
common := &commonOptions{}
|
||||||
|
flags.StringVar(&common.Endpoint, "endpoint", "localhost:5000", "gateway endpoint")
|
||||||
|
flags.StringVar(&common.APIKey, "api-key", "", "gateway API key")
|
||||||
|
flags.StringVar(&common.APIKeyEnv, "api-key-env", "MXGATEWAY_API_KEY", "environment variable containing the API key")
|
||||||
|
flags.BoolVar(&common.Plaintext, "plaintext", false, "use plaintext transport")
|
||||||
|
flags.StringVar(&common.CACertFile, "ca-cert", "", "CA certificate file")
|
||||||
|
flags.StringVar(&common.ServerName, "server-name-override", "", "TLS server name override")
|
||||||
|
flags.StringVar(&common.CallTimeout, "call-timeout", "30s", "per-call timeout")
|
||||||
|
return common
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialForCommand(ctx context.Context, common *commonOptions) (*mxgateway.Client, commonOptions, error) {
|
||||||
|
options, err := common.resolved()
|
||||||
|
if err != nil {
|
||||||
|
return nil, options, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := mxgateway.Dial(ctx, mxgateway.Options{
|
||||||
|
Endpoint: options.Endpoint,
|
||||||
|
APIKey: options.apiKeyValue,
|
||||||
|
Plaintext: options.Plaintext,
|
||||||
|
CACertFile: options.CACertFile,
|
||||||
|
ServerNameOverride: options.ServerName,
|
||||||
|
CallTimeout: options.timeout,
|
||||||
|
})
|
||||||
|
return client, options, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *commonOptions) resolved() (commonOptions, error) {
|
||||||
|
resolved := *o
|
||||||
|
if resolved.APIKey == "" && resolved.APIKeyEnv != "" {
|
||||||
|
resolved.apiKeyValue = os.Getenv(resolved.APIKeyEnv)
|
||||||
|
} else {
|
||||||
|
resolved.apiKeyValue = resolved.APIKey
|
||||||
|
}
|
||||||
|
resolved.APIKey = mxgateway.RedactAPIKey(resolved.apiKeyValue)
|
||||||
|
if resolved.CallTimeout != "" {
|
||||||
|
timeout, err := time.ParseDuration(resolved.CallTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return resolved, err
|
||||||
|
}
|
||||||
|
resolved.timeout = timeout
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseValue(valueType, valueText string) (*mxgateway.MxValue, error) {
|
||||||
|
switch valueType {
|
||||||
|
case "bool":
|
||||||
|
value, err := strconv.ParseBool(valueText)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mxgateway.BoolValue(value), nil
|
||||||
|
case "int32":
|
||||||
|
value, err := strconv.ParseInt(valueText, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mxgateway.Int32Value(int32(value)), nil
|
||||||
|
case "int64":
|
||||||
|
value, err := strconv.ParseInt(valueText, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mxgateway.Int64Value(value), nil
|
||||||
|
case "float":
|
||||||
|
value, err := strconv.ParseFloat(valueText, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mxgateway.FloatValue(float32(value)), nil
|
||||||
|
case "double":
|
||||||
|
value, err := strconv.ParseFloat(valueText, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mxgateway.DoubleValue(value), nil
|
||||||
|
case "string":
|
||||||
|
return mxgateway.StringValue(valueText), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported value type %q", valueType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCommandOutput(stdout io.Writer, jsonOutput bool, command string, options commonOptions, reply *mxgateway.MxCommandReply, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if jsonOutput {
|
||||||
|
return writeJSON(stdout, commandReplyOutput{
|
||||||
|
Command: command,
|
||||||
|
Options: options,
|
||||||
|
Reply: mustMarshalProto(reply),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fmt.Fprintln(stdout, reply.GetKind())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(writer io.Writer, value any) error {
|
||||||
|
encoder := json.NewEncoder(writer)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
return encoder.Encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMarshalProto(message protojsonMessage) json.RawMessage {
|
||||||
|
data, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(message)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
type protojsonMessage interface {
|
||||||
|
ProtoReflect() protoreflect.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeUsage(writer io.Writer) {
|
||||||
|
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|write|stream-events|smoke>")
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunVersionJSON(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
if err := runWithIO(t.Context(), []string{"version", "-json"}, &stdout, &stderr); err != nil {
|
||||||
|
t.Fatalf("runWithIO() error = %v; stderr = %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var output versionOutput
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
|
||||||
|
t.Fatalf("parse JSON: %v", err)
|
||||||
|
}
|
||||||
|
if output.GatewayProtocolVersion == 0 || output.WorkerProtocolVersion == 0 {
|
||||||
|
t.Fatalf("protocol versions were not populated: %+v", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommonOptionsRedactsAPIKey(t *testing.T) {
|
||||||
|
options, err := (&commonOptions{
|
||||||
|
Endpoint: "localhost:5000",
|
||||||
|
APIKey: "mxgw_super_secret",
|
||||||
|
Plaintext: true,
|
||||||
|
CallTimeout: "2s",
|
||||||
|
}).resolved()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolved() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal options: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "super_secret") {
|
||||||
|
t.Fatalf("redacted JSON leaked API key: %s", data)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "mxgw") {
|
||||||
|
t.Fatalf("redacted JSON did not preserve key shape: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseValueBuildsTypedValue(t *testing.T) {
|
||||||
|
value, err := parseValue("int32", "123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseValue() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := value.GetInt32Value(); got != 123 {
|
||||||
|
t.Fatalf("int32 value = %d, want 123", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
|
||||||
|
$protoRoot = Join-Path $repoRoot 'src\MxGateway.Contracts\Protos'
|
||||||
|
$outputRoot = Join-Path $PSScriptRoot 'internal\generated'
|
||||||
|
$modulePath = 'gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated'
|
||||||
|
$protoc = 'C:\Users\dohertj2\AppData\Local\Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe'
|
||||||
|
$goPluginPath = 'C:\Users\dohertj2\go\bin'
|
||||||
|
|
||||||
|
if (-not (Test-Path $protoc)) {
|
||||||
|
throw "protoc was not found at $protoc. See docs/toolchain-links.md."
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($pluginName in @('protoc-gen-go.exe', 'protoc-gen-go-grpc.exe')) {
|
||||||
|
$pluginPath = Join-Path $goPluginPath $pluginName
|
||||||
|
if (-not (Test-Path $pluginPath)) {
|
||||||
|
throw "$pluginName was not found at $pluginPath. See docs/toolchain-links.md."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null
|
||||||
|
Get-ChildItem -Path $outputRoot -Filter '*.pb.go' -File | Remove-Item
|
||||||
|
|
||||||
|
$env:Path = "$goPluginPath;$env:Path"
|
||||||
|
|
||||||
|
& $protoc `
|
||||||
|
--proto_path=$protoRoot `
|
||||||
|
--go_out=$outputRoot `
|
||||||
|
--go_opt=paths=source_relative `
|
||||||
|
"--go_opt=Mmxaccess_gateway.proto=$modulePath;generated" `
|
||||||
|
"--go_opt=Mmxaccess_worker.proto=$modulePath;generated" `
|
||||||
|
mxaccess_gateway.proto `
|
||||||
|
mxaccess_worker.proto
|
||||||
|
|
||||||
|
& $protoc `
|
||||||
|
--proto_path=$protoRoot `
|
||||||
|
--go-grpc_out=$outputRoot `
|
||||||
|
--go-grpc_opt=paths=source_relative `
|
||||||
|
"--go-grpc_opt=Mmxaccess_gateway.proto=$modulePath;generated" `
|
||||||
|
mxaccess_gateway.proto
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
module gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
google.golang.org/grpc v1.80.0
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
golang.org/x/net v0.49.0 // indirect
|
||||||
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
|
golang.org/x/text v0.33.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||||
|
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||||
|
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||||
|
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||||
|
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||||
|
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||||
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
|
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||||
|
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,243 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
|
// - protoc v7.34.1
|
||||||
|
// source: mxaccess_gateway.proto
|
||||||
|
|
||||||
|
package generated
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
|
const (
|
||||||
|
MxAccessGateway_OpenSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/OpenSession"
|
||||||
|
MxAccessGateway_CloseSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/CloseSession"
|
||||||
|
MxAccessGateway_Invoke_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/Invoke"
|
||||||
|
MxAccessGateway_StreamEvents_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamEvents"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MxAccessGatewayClient is the client API for MxAccessGateway service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
//
|
||||||
|
// Public client API for MXAccess sessions hosted by the gateway.
|
||||||
|
type MxAccessGatewayClient interface {
|
||||||
|
OpenSession(ctx context.Context, in *OpenSessionRequest, opts ...grpc.CallOption) (*OpenSessionReply, error)
|
||||||
|
CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionReply, error)
|
||||||
|
Invoke(ctx context.Context, in *MxCommandRequest, opts ...grpc.CallOption) (*MxCommandReply, error)
|
||||||
|
StreamEvents(ctx context.Context, in *StreamEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MxEvent], error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mxAccessGatewayClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMxAccessGatewayClient(cc grpc.ClientConnInterface) MxAccessGatewayClient {
|
||||||
|
return &mxAccessGatewayClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mxAccessGatewayClient) OpenSession(ctx context.Context, in *OpenSessionRequest, opts ...grpc.CallOption) (*OpenSessionReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(OpenSessionReply)
|
||||||
|
err := c.cc.Invoke(ctx, MxAccessGateway_OpenSession_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mxAccessGatewayClient) CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CloseSessionReply)
|
||||||
|
err := c.cc.Invoke(ctx, MxAccessGateway_CloseSession_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mxAccessGatewayClient) Invoke(ctx context.Context, in *MxCommandRequest, opts ...grpc.CallOption) (*MxCommandReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(MxCommandReply)
|
||||||
|
err := c.cc.Invoke(ctx, MxAccessGateway_Invoke_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *mxAccessGatewayClient) StreamEvents(ctx context.Context, in *StreamEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MxEvent], error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
stream, err := c.cc.NewStream(ctx, &MxAccessGateway_ServiceDesc.Streams[0], MxAccessGateway_StreamEvents_FullMethodName, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x := &grpc.GenericClientStream[StreamEventsRequest, MxEvent]{ClientStream: stream}
|
||||||
|
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := x.ClientStream.CloseSend(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type MxAccessGateway_StreamEventsClient = grpc.ServerStreamingClient[MxEvent]
|
||||||
|
|
||||||
|
// MxAccessGatewayServer is the server API for MxAccessGateway service.
|
||||||
|
// All implementations must embed UnimplementedMxAccessGatewayServer
|
||||||
|
// for forward compatibility.
|
||||||
|
//
|
||||||
|
// Public client API for MXAccess sessions hosted by the gateway.
|
||||||
|
type MxAccessGatewayServer interface {
|
||||||
|
OpenSession(context.Context, *OpenSessionRequest) (*OpenSessionReply, error)
|
||||||
|
CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionReply, error)
|
||||||
|
Invoke(context.Context, *MxCommandRequest) (*MxCommandReply, error)
|
||||||
|
StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[MxEvent]) error
|
||||||
|
mustEmbedUnimplementedMxAccessGatewayServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedMxAccessGatewayServer must be embedded to have
|
||||||
|
// forward compatible implementations.
|
||||||
|
//
|
||||||
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
// pointer dereference when methods are called.
|
||||||
|
type UnimplementedMxAccessGatewayServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedMxAccessGatewayServer) OpenSession(context.Context, *OpenSessionRequest) (*OpenSessionReply, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method OpenSession not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionReply, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method CloseSession not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) Invoke(context.Context, *MxCommandRequest) (*MxCommandReply, error) {
|
||||||
|
return nil, status.Error(codes.Unimplemented, "method Invoke not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[MxEvent]) error {
|
||||||
|
return status.Error(codes.Unimplemented, "method StreamEvents not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) mustEmbedUnimplementedMxAccessGatewayServer() {}
|
||||||
|
func (UnimplementedMxAccessGatewayServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
|
// UnsafeMxAccessGatewayServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to MxAccessGatewayServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafeMxAccessGatewayServer interface {
|
||||||
|
mustEmbedUnimplementedMxAccessGatewayServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterMxAccessGatewayServer(s grpc.ServiceRegistrar, srv MxAccessGatewayServer) {
|
||||||
|
// If the following call panics, it indicates UnimplementedMxAccessGatewayServer was
|
||||||
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
|
// time to prevent it from happening at runtime later due to I/O.
|
||||||
|
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||||
|
t.testEmbeddedByValue()
|
||||||
|
}
|
||||||
|
s.RegisterService(&MxAccessGateway_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _MxAccessGateway_OpenSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(OpenSessionRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(MxAccessGatewayServer).OpenSession(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: MxAccessGateway_OpenSession_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(MxAccessGatewayServer).OpenSession(ctx, req.(*OpenSessionRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _MxAccessGateway_CloseSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(CloseSessionRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(MxAccessGatewayServer).CloseSession(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: MxAccessGateway_CloseSession_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(MxAccessGatewayServer).CloseSession(ctx, req.(*CloseSessionRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _MxAccessGateway_Invoke_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(MxCommandRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(MxAccessGatewayServer).Invoke(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: MxAccessGateway_Invoke_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(MxAccessGatewayServer).Invoke(ctx, req.(*MxCommandRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _MxAccessGateway_StreamEvents_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||||
|
m := new(StreamEventsRequest)
|
||||||
|
if err := stream.RecvMsg(m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return srv.(MxAccessGatewayServer).StreamEvents(m, &grpc.GenericServerStream[StreamEventsRequest, MxEvent]{ServerStream: stream})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type MxAccessGateway_StreamEventsServer = grpc.ServerStreamingServer[MxEvent]
|
||||||
|
|
||||||
|
// MxAccessGateway_ServiceDesc is the grpc.ServiceDesc for MxAccessGateway service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var MxAccessGateway_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "mxaccess_gateway.v1.MxAccessGateway",
|
||||||
|
HandlerType: (*MxAccessGatewayServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "OpenSession",
|
||||||
|
Handler: _MxAccessGateway_OpenSession_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "CloseSession",
|
||||||
|
Handler: _MxAccessGateway_CloseSession_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "Invoke",
|
||||||
|
Handler: _MxAccessGateway_Invoke_Handler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{
|
||||||
|
{
|
||||||
|
StreamName: "StreamEvents",
|
||||||
|
Handler: _MxAccessGateway_StreamEvents_Handler,
|
||||||
|
ServerStreams: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Metadata: "mxaccess_gateway.proto",
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
const authorizationHeader = "authorization"
|
||||||
|
|
||||||
|
func unaryAuthInterceptor(apiKey string) grpc.UnaryClientInterceptor {
|
||||||
|
return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
|
||||||
|
return invoker(authContext(ctx, apiKey), method, req, reply, cc, opts...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamAuthInterceptor(apiKey string) grpc.StreamClientInterceptor {
|
||||||
|
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||||
|
return streamer(authContext(ctx, apiKey), desc, cc, method, opts...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authContext(ctx context.Context, apiKey string) context.Context {
|
||||||
|
if apiKey == "" {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata.AppendToOutgoingContext(ctx, authorizationHeader, "Bearer "+apiKey)
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/protobuf/types/known/durationpb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultDialTimeout = 10 * time.Second
|
||||||
|
defaultCallTimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client owns a gateway gRPC connection and exposes session-oriented helpers.
|
||||||
|
type Client struct {
|
||||||
|
conn *grpc.ClientConn
|
||||||
|
raw pb.MxAccessGatewayClient
|
||||||
|
opts Options
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial opens a gRPC connection to the gateway and configures auth metadata,
|
||||||
|
// transport security, and blocking dial cancellation from ctx.
|
||||||
|
func Dial(ctx context.Context, opts Options) (*Client, error) {
|
||||||
|
if opts.Endpoint == "" {
|
||||||
|
return nil, errors.New("mxgateway: endpoint is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
dialCtx := ctx
|
||||||
|
cancel := func() {}
|
||||||
|
if opts.DialTimeout > 0 {
|
||||||
|
dialCtx, cancel = context.WithTimeout(ctx, opts.DialTimeout)
|
||||||
|
} else if _, ok := ctx.Deadline(); !ok {
|
||||||
|
dialCtx, cancel = context.WithTimeout(ctx, defaultDialTimeout)
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
transportCredentials, err := resolveTransportCredentials(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dialOptions := []grpc.DialOption{
|
||||||
|
grpc.WithTransportCredentials(transportCredentials),
|
||||||
|
grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)),
|
||||||
|
grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)),
|
||||||
|
grpc.WithBlock(),
|
||||||
|
}
|
||||||
|
dialOptions = append(dialOptions, opts.DialOptions...)
|
||||||
|
|
||||||
|
conn, err := grpc.DialContext(dialCtx, opts.Endpoint, dialOptions...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "dial", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewClient(conn, opts), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient wraps an existing gRPC connection. The caller owns closing conn
|
||||||
|
// unless it calls Close on the returned Client.
|
||||||
|
func NewClient(conn *grpc.ClientConn, opts Options) *Client {
|
||||||
|
return &Client{
|
||||||
|
conn: conn,
|
||||||
|
raw: pb.NewMxAccessGatewayClient(conn),
|
||||||
|
opts: opts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawClient returns the generated gRPC client for command-specific parity tests.
|
||||||
|
func (c *Client) RawClient() RawGatewayClient {
|
||||||
|
return c.raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenSession creates a gateway-backed MXAccess session.
|
||||||
|
func (c *Client) OpenSession(ctx context.Context, opts OpenSessionOptions) (*Session, error) {
|
||||||
|
reply, err := c.OpenSessionRaw(ctx, opts.Request())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSession(c, reply), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenSessionRaw sends a raw OpenSession request and validates protocol status.
|
||||||
|
func (c *Client) OpenSessionRaw(ctx context.Context, req *OpenSessionRequest) (*OpenSessionReply, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New("mxgateway: open session request is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
callCtx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reply, err := c.raw.OpenSession(callCtx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "open session", Err: err}
|
||||||
|
}
|
||||||
|
if err := EnsureProtocolSuccess("open session", reply.GetProtocolStatus(), nil); err != nil {
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoke sends a raw MXAccess command request and validates protocol and
|
||||||
|
// MXAccess status fields while preserving the raw reply on typed errors.
|
||||||
|
func (c *Client) Invoke(ctx context.Context, req *MxCommandRequest) (*MxCommandReply, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New("mxgateway: command request is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
callCtx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reply, err := c.raw.Invoke(callCtx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "invoke", Err: err}
|
||||||
|
}
|
||||||
|
if err := EnsureProtocolSuccess("invoke", reply.GetProtocolStatus(), reply); err != nil {
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
|
if err := EnsureMxAccessSuccess("invoke", reply); err != nil {
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseSessionRaw sends a raw CloseSession request and validates protocol
|
||||||
|
// status.
|
||||||
|
func (c *Client) CloseSessionRaw(ctx context.Context, req *CloseSessionRequest) (*CloseSessionReply, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New("mxgateway: close session request is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
callCtx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
reply, err := c.raw.CloseSession(callCtx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "close session", Err: err}
|
||||||
|
}
|
||||||
|
if err := EnsureProtocolSuccess("close session", reply.GetProtocolStatus(), nil); err != nil {
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamEventsRaw starts the generated event stream for callers that need direct
|
||||||
|
// control over Recv.
|
||||||
|
func (c *Client) StreamEventsRaw(ctx context.Context, req *StreamEventsRequest) (RawEventStream, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, errors.New("mxgateway: stream events request is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
stream, err := c.raw.StreamEvents(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &GatewayError{Op: "stream events", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the underlying gRPC connection.
|
||||||
|
func (c *Client) Close() error {
|
||||||
|
if c == nil || c.conn == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
timeout := c.opts.CallTimeout
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = defaultCallTimeout
|
||||||
|
}
|
||||||
|
if timeout < 0 {
|
||||||
|
return ctx, func() {}
|
||||||
|
}
|
||||||
|
if _, ok := ctx.Deadline(); ok {
|
||||||
|
return ctx, func() {}
|
||||||
|
}
|
||||||
|
return context.WithTimeout(ctx, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveTransportCredentials(opts Options) (credentials.TransportCredentials, error) {
|
||||||
|
if opts.TransportCredentials != nil {
|
||||||
|
return opts.TransportCredentials, nil
|
||||||
|
}
|
||||||
|
if opts.Plaintext {
|
||||||
|
return insecure.NewCredentials(), nil
|
||||||
|
}
|
||||||
|
if opts.CACertFile != "" {
|
||||||
|
return credentials.NewClientTLSFromFile(opts.CACertFile, opts.ServerNameOverride)
|
||||||
|
}
|
||||||
|
if opts.TLSConfig != nil {
|
||||||
|
cfg := opts.TLSConfig.Clone()
|
||||||
|
if opts.ServerNameOverride != "" {
|
||||||
|
cfg.ServerName = opts.ServerNameOverride
|
||||||
|
}
|
||||||
|
return credentials.NewTLS(cfg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return credentials.NewTLS(&tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
ServerName: opts.ServerNameOverride,
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenSessionOptions describes fields used to create an OpenSessionRequest.
|
||||||
|
type OpenSessionOptions struct {
|
||||||
|
RequestedBackend string
|
||||||
|
ClientSessionName string
|
||||||
|
ClientCorrelationID string
|
||||||
|
CommandTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request returns the raw protobuf OpenSessionRequest for these options.
|
||||||
|
func (o OpenSessionOptions) Request() *OpenSessionRequest {
|
||||||
|
req := &OpenSessionRequest{
|
||||||
|
RequestedBackend: o.RequestedBackend,
|
||||||
|
ClientSessionName: o.ClientSessionName,
|
||||||
|
ClientCorrelationId: o.ClientCorrelationID,
|
||||||
|
}
|
||||||
|
if o.CommandTimeout > 0 {
|
||||||
|
req.CommandTimeout = durationpb.New(o.CommandTimeout)
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
"google.golang.org/grpc/test/bufconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bufSize = 1024 * 1024
|
||||||
|
|
||||||
|
func TestDialAttachesAuthMetadataToUnaryCalls(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
openReply: &pb.OpenSessionReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
GatewayProtocolVersion: GatewayProtocolVersion,
|
||||||
|
WorkerProtocolVersion: WorkerProtocolVersion,
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.OpenSession(context.Background(), OpenSessionOptions{ClientSessionName: "fixture"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenSession() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := fake.openAuth; got != "Bearer test-api-key" {
|
||||||
|
t.Fatalf("authorization metadata = %q, want %q", got, "Bearer test-api-key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamEventsAttachesAuthMetadataAndClosesOnCancellation(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
streamStarted: make(chan struct{}),
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
events, err := session.Events(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Events() error = %v", err)
|
||||||
|
}
|
||||||
|
<-fake.streamStarted
|
||||||
|
|
||||||
|
first := <-events
|
||||||
|
if first.Err != nil {
|
||||||
|
t.Fatalf("first event error = %v", first.Err)
|
||||||
|
}
|
||||||
|
if first.Event.GetWorkerSequence() != 1 {
|
||||||
|
t.Fatalf("worker sequence = %d, want 1", first.Event.GetWorkerSequence())
|
||||||
|
}
|
||||||
|
if got := fake.streamAuth; got != "Bearer test-api-key" {
|
||||||
|
t.Fatalf("stream authorization metadata = %q, want %q", got, "Bearer test-api-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case _, ok := <-events:
|
||||||
|
if ok {
|
||||||
|
t.Fatal("events channel produced an extra item after cancellation")
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("events channel did not close after cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionHelpersBuildCommandsAndExposeRawReply(t *testing.T) {
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
invokeReply: &pb.MxCommandReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2,
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
Payload: &pb.MxCommandReply_AddItem2{
|
||||||
|
AddItem2: &pb.AddItem2Reply{ItemHandle: 42},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
|
itemHandle, err := session.AddItem2(context.Background(), 12, "Area001.Pump001.Speed", "runtime")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddItem2() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if itemHandle != 42 {
|
||||||
|
t.Fatalf("item handle = %d, want 42", itemHandle)
|
||||||
|
}
|
||||||
|
req := fake.invokeRequest
|
||||||
|
if req.GetSessionId() != "session-1" {
|
||||||
|
t.Fatalf("session id = %q, want session-1", req.GetSessionId())
|
||||||
|
}
|
||||||
|
if req.GetClientCorrelationId() == "" {
|
||||||
|
t.Fatal("client correlation id is empty")
|
||||||
|
}
|
||||||
|
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2 {
|
||||||
|
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
|
||||||
|
}
|
||||||
|
if req.GetCommand().GetAddItem2().GetItemContext() != "runtime" {
|
||||||
|
t.Fatalf("item context = %q, want runtime", req.GetCommand().GetAddItem2().GetItemContext())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvokeReturnsTypedMxAccessErrorWithRawReply(t *testing.T) {
|
||||||
|
hresult := int32(-2147467259)
|
||||||
|
fake := &fakeGatewayServer{
|
||||||
|
invokeReply: &pb.MxCommandReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE,
|
||||||
|
Hresult: &hresult,
|
||||||
|
DiagnosticMessage: "native failure",
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE, Message: "MXAccess failed"},
|
||||||
|
Statuses: []*pb.MxStatusProxy{{Success: 0, DiagnosticText: "failed"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
client, cleanup := newBufconnClient(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
session := NewSessionForID(client, "session-1")
|
||||||
|
|
||||||
|
err := session.Advise(context.Background(), 12, 34)
|
||||||
|
|
||||||
|
var mxErr *MxAccessError
|
||||||
|
if !errors.As(err, &mxErr) {
|
||||||
|
t.Fatalf("error %T does not support errors.As(*MxAccessError)", err)
|
||||||
|
}
|
||||||
|
if mxErr.Reply.GetHresult() != hresult {
|
||||||
|
t.Fatalf("raw reply HRESULT = %d, want %d", mxErr.Reply.GetHresult(), hresult)
|
||||||
|
}
|
||||||
|
var commandErr *CommandError
|
||||||
|
if !errors.As(err, &commandErr) {
|
||||||
|
t.Fatalf("error %T does not support errors.As(*CommandError)", err)
|
||||||
|
}
|
||||||
|
if commandErr.Reply.GetDiagnosticMessage() != "native failure" {
|
||||||
|
t.Fatalf("raw diagnostic = %q", commandErr.Reply.GetDiagnosticMessage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBufconnClient(t *testing.T, fake *fakeGatewayServer) (*Client, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
listener := bufconn.Listen(bufSize)
|
||||||
|
server := grpc.NewServer()
|
||||||
|
pb.RegisterMxAccessGatewayServer(server, fake)
|
||||||
|
go func() {
|
||||||
|
if err := server.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
|
||||||
|
t.Errorf("bufconn server failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
|
||||||
|
return listener.DialContext(ctx)
|
||||||
|
}
|
||||||
|
client, err := Dial(context.Background(), Options{
|
||||||
|
Endpoint: "bufnet",
|
||||||
|
APIKey: "test-api-key",
|
||||||
|
Plaintext: true,
|
||||||
|
DialOptions: []grpc.DialOption{
|
||||||
|
grpc.WithContextDialer(dialer),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dial() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, func() {
|
||||||
|
client.Close()
|
||||||
|
server.Stop()
|
||||||
|
listener.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeGatewayServer struct {
|
||||||
|
pb.UnimplementedMxAccessGatewayServer
|
||||||
|
|
||||||
|
openReply *pb.OpenSessionReply
|
||||||
|
openAuth string
|
||||||
|
streamAuth string
|
||||||
|
streamStarted chan struct{}
|
||||||
|
invokeReply *pb.MxCommandReply
|
||||||
|
invokeRequest *pb.MxCommandRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGatewayServer) OpenSession(ctx context.Context, req *pb.OpenSessionRequest) (*pb.OpenSessionReply, error) {
|
||||||
|
s.openAuth = authorizationFromContext(ctx)
|
||||||
|
if s.openReply != nil {
|
||||||
|
return s.openReply, nil
|
||||||
|
}
|
||||||
|
return &pb.OpenSessionReply{
|
||||||
|
SessionId: "session-1",
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGatewayServer) CloseSession(ctx context.Context, req *pb.CloseSessionRequest) (*pb.CloseSessionReply, error) {
|
||||||
|
return &pb.CloseSessionReply{
|
||||||
|
SessionId: req.GetSessionId(),
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGatewayServer) Invoke(ctx context.Context, req *pb.MxCommandRequest) (*pb.MxCommandReply, error) {
|
||||||
|
s.invokeRequest = req
|
||||||
|
if s.invokeReply != nil {
|
||||||
|
return s.invokeReply, nil
|
||||||
|
}
|
||||||
|
return &pb.MxCommandReply{
|
||||||
|
SessionId: req.GetSessionId(),
|
||||||
|
Kind: req.GetCommand().GetKind(),
|
||||||
|
ProtocolStatus: &pb.ProtocolStatus{
|
||||||
|
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeGatewayServer) StreamEvents(req *pb.StreamEventsRequest, stream grpc.ServerStreamingServer[pb.MxEvent]) error {
|
||||||
|
s.streamAuth = authorizationFromContext(stream.Context())
|
||||||
|
if s.streamStarted != nil {
|
||||||
|
close(s.streamStarted)
|
||||||
|
}
|
||||||
|
if err := stream.Send(&pb.MxEvent{
|
||||||
|
SessionId: req.GetSessionId(),
|
||||||
|
Family: pb.MxEventFamily_MX_EVENT_FAMILY_ON_DATA_CHANGE,
|
||||||
|
WorkerSequence: 1,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
<-stream.Context().Done()
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizationFromContext(ctx context.Context) string {
|
||||||
|
md, ok := metadata.FromIncomingContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
values := md.Get(authorizationHeader)
|
||||||
|
if len(values) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return values[0]
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValueConversionFixtures(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(filepath.Join("..", "..", "proto", "fixtures", "behavior", "values", "value-conversion-cases.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fixture struct {
|
||||||
|
Cases []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ExpectedKind string `json:"expectedKind"`
|
||||||
|
Value json.RawMessage `json:"value"`
|
||||||
|
} `json:"cases"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &fixture); err != nil {
|
||||||
|
t.Fatalf("parse fixture manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range fixture.Cases {
|
||||||
|
t.Run(tc.ID, func(t *testing.T) {
|
||||||
|
var value pb.MxValue
|
||||||
|
if err := protojson.Unmarshal(tc.Value, &value); err != nil {
|
||||||
|
t.Fatalf("parse value: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := NativeValue(&value); err != nil {
|
||||||
|
t.Fatalf("NativeValue() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := value.ProtoReflect().WhichOneof(value.ProtoReflect().Descriptor().Oneofs().ByName("kind")).JSONName(); got != tc.ExpectedKind {
|
||||||
|
t.Fatalf("kind = %q, want %q", got, tc.ExpectedKind)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusConversionFixtures(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(filepath.Join("..", "..", "proto", "fixtures", "behavior", "statuses", "status-conversion-cases.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fixture struct {
|
||||||
|
Cases []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status json.RawMessage `json:"status"`
|
||||||
|
} `json:"cases"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &fixture); err != nil {
|
||||||
|
t.Fatalf("parse fixture manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range fixture.Cases {
|
||||||
|
t.Run(tc.ID, func(t *testing.T) {
|
||||||
|
var status pb.MxStatusProxy
|
||||||
|
if err := protojson.Unmarshal(tc.Status, &status); err != nil {
|
||||||
|
t.Fatalf("parse status: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := StatusSucceeded(&status), status.GetSuccess() != 0; got != want {
|
||||||
|
t.Fatalf("StatusSucceeded() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GatewayError wraps transport-level gRPC failures.
|
||||||
|
type GatewayError struct {
|
||||||
|
Op string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GatewayError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if e.Op == "" {
|
||||||
|
return fmt.Sprintf("mxgateway: %v", e.Err)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("mxgateway: %s failed: %v", e.Op, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *GatewayError) Unwrap() error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommandError reports a non-OK gateway protocol status and keeps the raw
|
||||||
|
// command reply when one exists.
|
||||||
|
type CommandError struct {
|
||||||
|
Op string
|
||||||
|
Status *ProtocolStatus
|
||||||
|
Reply *MxCommandReply
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *CommandError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
status := e.Status
|
||||||
|
if status == nil {
|
||||||
|
return fmt.Sprintf("mxgateway: %s failed with missing protocol status", e.Op)
|
||||||
|
}
|
||||||
|
if status.GetMessage() == "" {
|
||||||
|
return fmt.Sprintf("mxgateway: %s failed with protocol status %s", e.Op, status.GetCode())
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("mxgateway: %s failed with protocol status %s: %s", e.Op, status.GetCode(), status.GetMessage())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MxAccessError reports HRESULT or MXSTATUS_PROXY failures returned by MXAccess.
|
||||||
|
type MxAccessError struct {
|
||||||
|
Command *CommandError
|
||||||
|
Reply *MxCommandReply
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MxAccessError) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if e.Command != nil && e.Command.Status != nil && e.Command.Status.GetMessage() != "" {
|
||||||
|
return e.Command.Error()
|
||||||
|
}
|
||||||
|
if e.Reply != nil && e.Reply.GetDiagnosticMessage() != "" {
|
||||||
|
return fmt.Sprintf("mxgateway: MXAccess command %s failed: %s", e.Reply.GetKind(), e.Reply.GetDiagnosticMessage())
|
||||||
|
}
|
||||||
|
if e.Reply != nil && e.Reply.Hresult != nil {
|
||||||
|
return fmt.Sprintf("mxgateway: MXAccess command %s failed with HRESULT 0x%08X", e.Reply.GetKind(), uint32(e.Reply.GetHresult()))
|
||||||
|
}
|
||||||
|
return "mxgateway: MXAccess command failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MxAccessError) Unwrap() error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return e.Command
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureProtocolSuccess returns a typed CommandError when status is non-OK.
|
||||||
|
func EnsureProtocolSuccess(op string, status *ProtocolStatus, reply *MxCommandReply) error {
|
||||||
|
if status == nil || status.GetCode() == pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
commandError := &CommandError{
|
||||||
|
Op: op,
|
||||||
|
Status: status,
|
||||||
|
Reply: reply,
|
||||||
|
}
|
||||||
|
if status.GetCode() == pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE {
|
||||||
|
return &MxAccessError{
|
||||||
|
Command: commandError,
|
||||||
|
Reply: reply,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return commandError
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureMxAccessSuccess returns a typed MxAccessError for failing HRESULTs or
|
||||||
|
// MXSTATUS_PROXY entries.
|
||||||
|
func EnsureMxAccessSuccess(op string, reply *MxCommandReply) error {
|
||||||
|
if reply == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if reply.Hresult != nil && reply.GetHresult() != 0 {
|
||||||
|
return &MxAccessError{Reply: reply}
|
||||||
|
}
|
||||||
|
for _, status := range reply.GetStatuses() {
|
||||||
|
if !StatusSucceeded(status) {
|
||||||
|
return &MxAccessError{Reply: reply}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options configures gateway connections.
|
||||||
|
type Options struct {
|
||||||
|
Endpoint string
|
||||||
|
APIKey string
|
||||||
|
Plaintext bool
|
||||||
|
CACertFile string
|
||||||
|
ServerNameOverride string
|
||||||
|
DialTimeout time.Duration
|
||||||
|
CallTimeout time.Duration
|
||||||
|
TLSConfig *tls.Config
|
||||||
|
TransportCredentials credentials.TransportCredentials
|
||||||
|
DialOptions []grpc.DialOption
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactedAPIKey returns a display-safe representation of the configured API
|
||||||
|
// key for diagnostics and CLI output.
|
||||||
|
func (o Options) RedactedAPIKey() string {
|
||||||
|
return RedactAPIKey(o.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactAPIKey hides credential material while keeping enough shape for
|
||||||
|
// troubleshooting whether a key was supplied.
|
||||||
|
func RedactAPIKey(apiKey string) string {
|
||||||
|
if apiKey == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(apiKey) <= 8 {
|
||||||
|
return "<redacted>"
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix, suffix := apiKey[:4], apiKey[len(apiKey)-4:]
|
||||||
|
return prefix + strings.Repeat("*", len(apiKey)-8) + suffix
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRedactAPIKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
apiKey string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "empty", apiKey: "", want: ""},
|
||||||
|
{name: "short", apiKey: "mxgw_1", want: "<redacted>"},
|
||||||
|
{name: "long", apiKey: "mxgw_key_secret", want: "mxgw*******cret"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := RedactAPIKey(tt.apiKey); got != tt.want {
|
||||||
|
t.Fatalf("RedactAPIKey() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/protobuf/encoding/protojson"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGeneratedGoldenFixturesParse(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
msg proto.Message
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "open session reply",
|
||||||
|
path: filepath.Join("..", "..", "proto", "fixtures", "golden", "open-session-reply.ok.json"),
|
||||||
|
msg: &pb.OpenSessionReply{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "register command request",
|
||||||
|
path: filepath.Join("..", "..", "proto", "fixtures", "golden", "register-command-request.json"),
|
||||||
|
msg: &pb.MxCommandRequest{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "on data change event",
|
||||||
|
path: filepath.Join("..", "..", "proto", "fixtures", "golden", "on-data-change-event.json"),
|
||||||
|
msg: &pb.MxEvent{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
unmarshal := protojson.UnmarshalOptions{DiscardUnknown: false}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(tt.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := unmarshal.Unmarshal(data, tt.msg); err != nil {
|
||||||
|
t.Fatalf("parse fixture: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenSessionFixtureProtocolVersions(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(filepath.Join("..", "..", "proto", "fixtures", "golden", "open-session-reply.ok.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reply pb.OpenSessionReply
|
||||||
|
if err := protojson.Unmarshal(data, &reply); err != nil {
|
||||||
|
t.Fatalf("parse fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply.GetGatewayProtocolVersion() != GatewayProtocolVersion {
|
||||||
|
t.Fatalf("gateway protocol = %d, want %d", reply.GetGatewayProtocolVersion(), GatewayProtocolVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply.GetWorkerProtocolVersion() != WorkerProtocolVersion {
|
||||||
|
t.Fatalf("worker protocol = %d, want %d", reply.GetWorkerProtocolVersion(), WorkerProtocolVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventResult carries either the next ordered event or a terminal stream error.
|
||||||
|
type EventResult struct {
|
||||||
|
Event *MxEvent
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session represents one gateway-backed MXAccess session.
|
||||||
|
type Session struct {
|
||||||
|
client *Client
|
||||||
|
openReply *OpenSessionReply
|
||||||
|
closeMu sync.Mutex
|
||||||
|
closeReply *CloseSessionReply
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSession(client *Client, openReply *OpenSessionReply) *Session {
|
||||||
|
return &Session{
|
||||||
|
client: client,
|
||||||
|
openReply: openReply,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSessionForID creates a session wrapper for commands against an existing
|
||||||
|
// gateway session id.
|
||||||
|
func NewSessionForID(client *Client, sessionID string) *Session {
|
||||||
|
return newSession(client, &pb.OpenSessionReply{SessionId: sessionID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID returns the gateway session identifier.
|
||||||
|
func (s *Session) ID() string {
|
||||||
|
return s.openReply.GetSessionId()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenReply returns the raw OpenSession reply.
|
||||||
|
func (s *Session) OpenReply() *OpenSessionReply {
|
||||||
|
return s.openReply
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the gateway session once and returns the raw close reply.
|
||||||
|
func (s *Session) Close(ctx context.Context) (*CloseSessionReply, error) {
|
||||||
|
s.closeMu.Lock()
|
||||||
|
defer s.closeMu.Unlock()
|
||||||
|
|
||||||
|
if s.closeReply != nil {
|
||||||
|
return s.closeReply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, err := s.client.CloseSessionRaw(ctx, &pb.CloseSessionRequest{SessionId: s.ID()})
|
||||||
|
if err != nil {
|
||||||
|
return reply, err
|
||||||
|
}
|
||||||
|
s.closeReply = reply
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register invokes MXAccess Register and returns the server handle.
|
||||||
|
func (s *Session) Register(ctx context.Context, clientName string) (int32, error) {
|
||||||
|
reply, err := s.RegisterRaw(ctx, clientName)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if reply.GetRegister() != nil {
|
||||||
|
return reply.GetRegister().GetServerHandle(), nil
|
||||||
|
}
|
||||||
|
return reply.GetReturnValue().GetInt32Value(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRaw invokes MXAccess Register and returns the raw reply.
|
||||||
|
func (s *Session) RegisterRaw(ctx context.Context, clientName string) (*MxCommandReply, error) {
|
||||||
|
if clientName == "" {
|
||||||
|
return nil, errors.New("mxgateway: client name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_REGISTER,
|
||||||
|
Payload: &pb.MxCommand_Register{
|
||||||
|
Register: &pb.RegisterCommand{ClientName: clientName},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister invokes MXAccess Unregister.
|
||||||
|
func (s *Session) Unregister(ctx context.Context, serverHandle int32) error {
|
||||||
|
_, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_UNREGISTER,
|
||||||
|
Payload: &pb.MxCommand_Unregister{
|
||||||
|
Unregister: &pb.UnregisterCommand{ServerHandle: serverHandle},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItem invokes MXAccess AddItem and returns the item handle.
|
||||||
|
func (s *Session) AddItem(ctx context.Context, serverHandle int32, itemDefinition string) (int32, error) {
|
||||||
|
reply, err := s.AddItemRaw(ctx, serverHandle, itemDefinition)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if reply.GetAddItem() != nil {
|
||||||
|
return reply.GetAddItem().GetItemHandle(), nil
|
||||||
|
}
|
||||||
|
return reply.GetReturnValue().GetInt32Value(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItemRaw invokes MXAccess AddItem and returns the raw reply.
|
||||||
|
func (s *Session) AddItemRaw(ctx context.Context, serverHandle int32, itemDefinition string) (*MxCommandReply, error) {
|
||||||
|
if itemDefinition == "" {
|
||||||
|
return nil, errors.New("mxgateway: item definition is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM,
|
||||||
|
Payload: &pb.MxCommand_AddItem{
|
||||||
|
AddItem: &pb.AddItemCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemDefinition: itemDefinition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItem2 invokes MXAccess AddItem2 and returns the item handle.
|
||||||
|
func (s *Session) AddItem2(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (int32, error) {
|
||||||
|
reply, err := s.AddItem2Raw(ctx, serverHandle, itemDefinition, itemContext)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if reply.GetAddItem2() != nil {
|
||||||
|
return reply.GetAddItem2().GetItemHandle(), nil
|
||||||
|
}
|
||||||
|
return reply.GetReturnValue().GetInt32Value(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItem2Raw invokes MXAccess AddItem2 and returns the raw reply.
|
||||||
|
func (s *Session) AddItem2Raw(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (*MxCommandReply, error) {
|
||||||
|
if itemDefinition == "" {
|
||||||
|
return nil, errors.New("mxgateway: item definition is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2,
|
||||||
|
Payload: &pb.MxCommand_AddItem2{
|
||||||
|
AddItem2: &pb.AddItem2Command{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemDefinition: itemDefinition,
|
||||||
|
ItemContext: itemContext,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advise invokes MXAccess Advise.
|
||||||
|
func (s *Session) Advise(ctx context.Context, serverHandle, itemHandle int32) error {
|
||||||
|
_, err := s.AdviseRaw(ctx, serverHandle, itemHandle)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdviseRaw invokes MXAccess Advise and returns the raw reply.
|
||||||
|
func (s *Session) AdviseRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
|
||||||
|
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE,
|
||||||
|
Payload: &pb.MxCommand_Advise{
|
||||||
|
Advise: &pb.AdviseCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemHandle: itemHandle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write invokes MXAccess Write.
|
||||||
|
func (s *Session) Write(ctx context.Context, serverHandle, itemHandle int32, value *MxValue, userID int32) error {
|
||||||
|
_, err := s.WriteRaw(ctx, serverHandle, itemHandle, value, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteRaw invokes MXAccess Write and returns the raw reply.
|
||||||
|
func (s *Session) WriteRaw(ctx context.Context, serverHandle, itemHandle int32, value *MxValue, userID int32) (*MxCommandReply, error) {
|
||||||
|
if value == nil {
|
||||||
|
return nil, errors.New("mxgateway: write value is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.invokeCommand(ctx, &pb.MxCommand{
|
||||||
|
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE,
|
||||||
|
Payload: &pb.MxCommand_Write{
|
||||||
|
Write: &pb.WriteCommand{
|
||||||
|
ServerHandle: serverHandle,
|
||||||
|
ItemHandle: itemHandle,
|
||||||
|
Value: value,
|
||||||
|
UserId: userID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events streams ordered session events until the server ends the stream,
|
||||||
|
// context cancellation stops Recv, or a terminal error is sent.
|
||||||
|
func (s *Session) Events(ctx context.Context) (<-chan EventResult, error) {
|
||||||
|
return s.EventsAfter(ctx, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventsAfter streams ordered session events after the given worker sequence.
|
||||||
|
func (s *Session) EventsAfter(ctx context.Context, afterWorkerSequence uint64) (<-chan EventResult, error) {
|
||||||
|
stream, err := s.client.StreamEventsRaw(ctx, &pb.StreamEventsRequest{
|
||||||
|
SessionId: s.ID(),
|
||||||
|
AfterWorkerSequence: afterWorkerSequence,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make(chan EventResult, 16)
|
||||||
|
go func() {
|
||||||
|
defer close(results)
|
||||||
|
for {
|
||||||
|
event, err := stream.Recv()
|
||||||
|
if err == nil {
|
||||||
|
results <- EventResult{Event: event}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err == io.EOF || status.Code(err) == codes.Canceled || ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results <- EventResult{Err: &GatewayError{Op: "stream events", Err: err}}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) invokeCommand(ctx context.Context, command *MxCommand) (*MxCommandReply, error) {
|
||||||
|
return s.client.Invoke(ctx, &pb.MxCommandRequest{
|
||||||
|
SessionId: s.ID(),
|
||||||
|
ClientCorrelationId: newCorrelationID(),
|
||||||
|
Command: command,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCorrelationID() string {
|
||||||
|
var buffer [16]byte
|
||||||
|
if _, err := rand.Read(buffer[:]); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buffer[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
// StatusSucceeded reports whether an MXSTATUS_PROXY entry represents success.
|
||||||
|
func StatusSucceeded(status *MxStatusProxy) bool {
|
||||||
|
return status == nil || status.GetSuccess() != 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
|
||||||
|
// RawGatewayClient is the generated gRPC client interface exposed for callers
|
||||||
|
// that need direct contract access.
|
||||||
|
type RawGatewayClient = pb.MxAccessGatewayClient
|
||||||
|
|
||||||
|
// RawEventStream is the generated StreamEvents client stream.
|
||||||
|
type RawEventStream = pb.MxAccessGateway_StreamEventsClient
|
||||||
|
|
||||||
|
// Generated protobuf aliases keep raw contract access available from the public
|
||||||
|
// mxgateway package while generated code remains under internal/generated.
|
||||||
|
type (
|
||||||
|
OpenSessionRequest = pb.OpenSessionRequest
|
||||||
|
OpenSessionReply = pb.OpenSessionReply
|
||||||
|
CloseSessionRequest = pb.CloseSessionRequest
|
||||||
|
CloseSessionReply = pb.CloseSessionReply
|
||||||
|
StreamEventsRequest = pb.StreamEventsRequest
|
||||||
|
MxCommandRequest = pb.MxCommandRequest
|
||||||
|
MxCommandReply = pb.MxCommandReply
|
||||||
|
MxCommand = pb.MxCommand
|
||||||
|
MxEvent = pb.MxEvent
|
||||||
|
MxValue = pb.MxValue
|
||||||
|
Value = pb.MxValue
|
||||||
|
MxArray = pb.MxArray
|
||||||
|
MxStatusProxy = pb.MxStatusProxy
|
||||||
|
ProtocolStatus = pb.ProtocolStatus
|
||||||
|
RegisterCommand = pb.RegisterCommand
|
||||||
|
UnregisterCommand = pb.UnregisterCommand
|
||||||
|
AddItemCommand = pb.AddItemCommand
|
||||||
|
AddItem2Command = pb.AddItem2Command
|
||||||
|
AdviseCommand = pb.AdviseCommand
|
||||||
|
WriteCommand = pb.WriteCommand
|
||||||
|
Write2Command = pb.Write2Command
|
||||||
|
RegisterReply = pb.RegisterReply
|
||||||
|
AddItemReply = pb.AddItemReply
|
||||||
|
AddItem2Reply = pb.AddItem2Reply
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
MxCommandKind = pb.MxCommandKind
|
||||||
|
MxDataType = pb.MxDataType
|
||||||
|
MxEventFamily = pb.MxEventFamily
|
||||||
|
MxStatusCategory = pb.MxStatusCategory
|
||||||
|
MxStatusSource = pb.MxStatusSource
|
||||||
|
ProtocolStatusCode = pb.ProtocolStatusCode
|
||||||
|
SessionState = pb.SessionState
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CommandKindRegister = pb.MxCommandKind_MX_COMMAND_KIND_REGISTER
|
||||||
|
CommandKindUnregister = pb.MxCommandKind_MX_COMMAND_KIND_UNREGISTER
|
||||||
|
CommandKindAddItem = pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM
|
||||||
|
CommandKindAddItem2 = pb.MxCommandKind_MX_COMMAND_KIND_ADD_ITEM2
|
||||||
|
CommandKindAdvise = pb.MxCommandKind_MX_COMMAND_KIND_ADVISE
|
||||||
|
CommandKindWrite = pb.MxCommandKind_MX_COMMAND_KIND_WRITE
|
||||||
|
CommandKindWrite2 = pb.MxCommandKind_MX_COMMAND_KIND_WRITE2
|
||||||
|
|
||||||
|
DataTypeUnknown = pb.MxDataType_MX_DATA_TYPE_UNKNOWN
|
||||||
|
DataTypeBoolean = pb.MxDataType_MX_DATA_TYPE_BOOLEAN
|
||||||
|
DataTypeInteger = pb.MxDataType_MX_DATA_TYPE_INTEGER
|
||||||
|
DataTypeFloat = pb.MxDataType_MX_DATA_TYPE_FLOAT
|
||||||
|
DataTypeDouble = pb.MxDataType_MX_DATA_TYPE_DOUBLE
|
||||||
|
DataTypeString = pb.MxDataType_MX_DATA_TYPE_STRING
|
||||||
|
DataTypeTime = pb.MxDataType_MX_DATA_TYPE_TIME
|
||||||
|
|
||||||
|
ProtocolStatusOK = pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK
|
||||||
|
ProtocolStatusMxAccessFailure = pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE
|
||||||
|
)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BoolValue builds an MXAccess Boolean value.
|
||||||
|
func BoolValue(value bool) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_BOOLEAN,
|
||||||
|
VariantType: "VT_BOOL",
|
||||||
|
Kind: &pb.MxValue_BoolValue{BoolValue: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Int32Value builds an MXAccess Int32 value.
|
||||||
|
func Int32Value(value int32) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER,
|
||||||
|
VariantType: "VT_I4",
|
||||||
|
Kind: &pb.MxValue_Int32Value{Int32Value: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Int64Value builds an MXAccess Int64 value.
|
||||||
|
func Int64Value(value int64) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER,
|
||||||
|
VariantType: "VT_I8",
|
||||||
|
Kind: &pb.MxValue_Int64Value{Int64Value: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FloatValue builds an MXAccess Float value.
|
||||||
|
func FloatValue(value float32) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_FLOAT,
|
||||||
|
VariantType: "VT_R4",
|
||||||
|
Kind: &pb.MxValue_FloatValue{FloatValue: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoubleValue builds an MXAccess Double value.
|
||||||
|
func DoubleValue(value float64) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_DOUBLE,
|
||||||
|
VariantType: "VT_R8",
|
||||||
|
Kind: &pb.MxValue_DoubleValue{DoubleValue: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StringValue builds an MXAccess String value.
|
||||||
|
func StringValue(value string) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_STRING,
|
||||||
|
VariantType: "VT_BSTR",
|
||||||
|
Kind: &pb.MxValue_StringValue{StringValue: value},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimestampValue builds an MXAccess timestamp value from a Go time.
|
||||||
|
func TimestampValue(value time.Time) *MxValue {
|
||||||
|
return &pb.MxValue{
|
||||||
|
DataType: pb.MxDataType_MX_DATA_TYPE_TIME,
|
||||||
|
VariantType: "VT_DATE",
|
||||||
|
Kind: &pb.MxValue_TimestampValue{TimestampValue: timestamppb.New(value)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NativeValue converts a protobuf MxValue to the closest Go representation
|
||||||
|
// without discarding raw fallback data.
|
||||||
|
func NativeValue(value *MxValue) (any, error) {
|
||||||
|
if value == nil || value.GetIsNull() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch kind := value.GetKind().(type) {
|
||||||
|
case *pb.MxValue_BoolValue:
|
||||||
|
return kind.BoolValue, nil
|
||||||
|
case *pb.MxValue_Int32Value:
|
||||||
|
return kind.Int32Value, nil
|
||||||
|
case *pb.MxValue_Int64Value:
|
||||||
|
return kind.Int64Value, nil
|
||||||
|
case *pb.MxValue_FloatValue:
|
||||||
|
return kind.FloatValue, nil
|
||||||
|
case *pb.MxValue_DoubleValue:
|
||||||
|
return kind.DoubleValue, nil
|
||||||
|
case *pb.MxValue_StringValue:
|
||||||
|
return kind.StringValue, nil
|
||||||
|
case *pb.MxValue_TimestampValue:
|
||||||
|
if kind.TimestampValue == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return kind.TimestampValue.AsTime(), nil
|
||||||
|
case *pb.MxValue_ArrayValue:
|
||||||
|
return NativeArray(kind.ArrayValue)
|
||||||
|
case *pb.MxValue_RawValue:
|
||||||
|
return append([]byte(nil), kind.RawValue...), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("mxgateway: unsupported value kind %T", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NativeArray converts a protobuf MxArray to the closest Go slice
|
||||||
|
// representation.
|
||||||
|
func NativeArray(array *MxArray) (any, error) {
|
||||||
|
if array == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch values := array.GetValues().(type) {
|
||||||
|
case *pb.MxArray_BoolValues:
|
||||||
|
return append([]bool(nil), values.BoolValues.GetValues()...), nil
|
||||||
|
case *pb.MxArray_Int32Values:
|
||||||
|
return append([]int32(nil), values.Int32Values.GetValues()...), nil
|
||||||
|
case *pb.MxArray_Int64Values:
|
||||||
|
return append([]int64(nil), values.Int64Values.GetValues()...), nil
|
||||||
|
case *pb.MxArray_FloatValues:
|
||||||
|
return append([]float32(nil), values.FloatValues.GetValues()...), nil
|
||||||
|
case *pb.MxArray_DoubleValues:
|
||||||
|
return append([]float64(nil), values.DoubleValues.GetValues()...), nil
|
||||||
|
case *pb.MxArray_StringValues:
|
||||||
|
return append([]string(nil), values.StringValues.GetValues()...), nil
|
||||||
|
case *pb.MxArray_TimestampValues:
|
||||||
|
result := make([]time.Time, 0, len(values.TimestampValues.GetValues()))
|
||||||
|
for _, value := range values.TimestampValues.GetValues() {
|
||||||
|
if value == nil {
|
||||||
|
result = append(result, time.Time{})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, value.AsTime())
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
case *pb.MxArray_RawValues:
|
||||||
|
rawValues := values.RawValues.GetValues()
|
||||||
|
result := make([][]byte, 0, len(rawValues))
|
||||||
|
for _, value := range rawValues {
|
||||||
|
result = append(result, append([]byte(nil), value...))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("mxgateway: unsupported array value kind %T", values)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ClientVersion identifies this Go client scaffold before package releases
|
||||||
|
// assign semantic versions.
|
||||||
|
ClientVersion = "0.1.0-dev"
|
||||||
|
|
||||||
|
// GatewayProtocolVersion matches GatewayContractInfo.GatewayProtocolVersion
|
||||||
|
// in the shared .NET contracts.
|
||||||
|
GatewayProtocolVersion uint32 = 1
|
||||||
|
|
||||||
|
// WorkerProtocolVersion matches GatewayContractInfo.WorkerProtocolVersion
|
||||||
|
// and is exposed for fake-worker and parity tests.
|
||||||
|
WorkerProtocolVersion uint32 = 1
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "missing-api-key",
|
||||||
|
"grpcStatusCode": "UNAUTHENTICATED",
|
||||||
|
"clientErrorCategory": "AuthenticationError",
|
||||||
|
"inputMetadata": {
|
||||||
|
"authorization": ""
|
||||||
|
},
|
||||||
|
"expectedRedactedOutput": "authentication failed: missing bearer token",
|
||||||
|
"retryableWithoutCredentialChange": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "invalid-api-key",
|
||||||
|
"grpcStatusCode": "UNAUTHENTICATED",
|
||||||
|
"clientErrorCategory": "AuthenticationError",
|
||||||
|
"inputMetadata": {
|
||||||
|
"authorization": "Bearer <redacted>"
|
||||||
|
},
|
||||||
|
"expectedRedactedOutput": "authentication failed: invalid API key <redacted>",
|
||||||
|
"retryableWithoutCredentialChange": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "missing-write-scope",
|
||||||
|
"grpcStatusCode": "PERMISSION_DENIED",
|
||||||
|
"clientErrorCategory": "AuthorizationError",
|
||||||
|
"inputMetadata": {
|
||||||
|
"authorization": "Bearer <redacted>"
|
||||||
|
},
|
||||||
|
"requiredScope": "mxaccess.write",
|
||||||
|
"expectedRedactedOutput": "authorization failed: missing scope mxaccess.write",
|
||||||
|
"retryableWithoutCredentialChange": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"sessionId": "session-fixture",
|
||||||
|
"correlationId": "gateway-correlation-register-1",
|
||||||
|
"kind": "MX_COMMAND_KIND_REGISTER",
|
||||||
|
"protocolStatus": {
|
||||||
|
"code": "PROTOCOL_STATUS_CODE_OK",
|
||||||
|
"message": "Register completed."
|
||||||
|
},
|
||||||
|
"hresult": 0,
|
||||||
|
"returnValue": {
|
||||||
|
"dataType": "MX_DATA_TYPE_INTEGER",
|
||||||
|
"variantType": "VT_I4",
|
||||||
|
"int32Value": 12
|
||||||
|
},
|
||||||
|
"statuses": [
|
||||||
|
{
|
||||||
|
"success": 1,
|
||||||
|
"category": "MX_STATUS_CATEGORY_OK",
|
||||||
|
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX",
|
||||||
|
"detail": 0,
|
||||||
|
"rawCategory": 0,
|
||||||
|
"rawDetectedBy": 0,
|
||||||
|
"diagnosticText": "OK"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"diagnosticMessage": "COM Register returned server handle 12.",
|
||||||
|
"register": {
|
||||||
|
"serverHandle": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"sessionId": "session-fixture",
|
||||||
|
"correlationId": "gateway-correlation-write-1",
|
||||||
|
"kind": "MX_COMMAND_KIND_WRITE",
|
||||||
|
"protocolStatus": {
|
||||||
|
"code": "PROTOCOL_STATUS_CODE_MXACCESS_FAILURE",
|
||||||
|
"message": "MXAccess rejected the write."
|
||||||
|
},
|
||||||
|
"hresult": -2147220992,
|
||||||
|
"returnValue": {
|
||||||
|
"dataType": "MX_DATA_TYPE_NO_DATA",
|
||||||
|
"variantType": "VT_EMPTY",
|
||||||
|
"isNull": true,
|
||||||
|
"rawDiagnostic": "MXAccess returned no value for the failed write.",
|
||||||
|
"rawDataType": 2
|
||||||
|
},
|
||||||
|
"statuses": [
|
||||||
|
{
|
||||||
|
"success": 0,
|
||||||
|
"category": "MX_STATUS_CATEGORY_SECURITY_ERROR",
|
||||||
|
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX",
|
||||||
|
"detail": 321,
|
||||||
|
"rawCategory": 8,
|
||||||
|
"rawDetectedBy": 3,
|
||||||
|
"diagnosticText": "Write denied by provider security."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"success": 0,
|
||||||
|
"category": "MX_STATUS_CATEGORY_OPERATIONAL_ERROR",
|
||||||
|
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_NMX",
|
||||||
|
"detail": 902,
|
||||||
|
"rawCategory": 7,
|
||||||
|
"rawDetectedBy": 5,
|
||||||
|
"diagnosticText": "Provider rejected the item state."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"diagnosticMessage": "Fixture preserves a data-bearing MXAccess failure reply with HRESULT and status array."
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
{
|
||||||
|
"sessionId": "session-fixture",
|
||||||
|
"description": "Ordered event stream sample for one worker-backed session.",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"family": "MX_EVENT_FAMILY_ON_DATA_CHANGE",
|
||||||
|
"sessionId": "session-fixture",
|
||||||
|
"serverHandle": 12,
|
||||||
|
"itemHandle": 34,
|
||||||
|
"value": {
|
||||||
|
"dataType": "MX_DATA_TYPE_INTEGER",
|
||||||
|
"variantType": "VT_I4",
|
||||||
|
"int32Value": 123
|
||||||
|
},
|
||||||
|
"quality": 192,
|
||||||
|
"sourceTimestamp": "2026-01-01T00:00:00Z",
|
||||||
|
"statuses": [
|
||||||
|
{
|
||||||
|
"success": 1,
|
||||||
|
"category": "MX_STATUS_CATEGORY_OK",
|
||||||
|
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX",
|
||||||
|
"detail": 0,
|
||||||
|
"rawCategory": 0,
|
||||||
|
"rawDetectedBy": 0,
|
||||||
|
"diagnosticText": "OK"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"workerSequence": "1",
|
||||||
|
"workerTimestamp": "2026-01-01T00:00:00.010Z",
|
||||||
|
"gatewayReceiveTimestamp": "2026-01-01T00:00:00.015Z",
|
||||||
|
"onDataChange": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "MX_EVENT_FAMILY_ON_WRITE_COMPLETE",
|
||||||
|
"sessionId": "session-fixture",
|
||||||
|
"serverHandle": 12,
|
||||||
|
"itemHandle": 34,
|
||||||
|
"value": {
|
||||||
|
"dataType": "MX_DATA_TYPE_DOUBLE",
|
||||||
|
"variantType": "VT_R8",
|
||||||
|
"doubleValue": 45.5
|
||||||
|
},
|
||||||
|
"quality": 192,
|
||||||
|
"sourceTimestamp": "2026-01-01T00:00:01Z",
|
||||||
|
"statuses": [
|
||||||
|
{
|
||||||
|
"success": 1,
|
||||||
|
"category": "MX_STATUS_CATEGORY_OK",
|
||||||
|
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX",
|
||||||
|
"detail": 0,
|
||||||
|
"rawCategory": 0,
|
||||||
|
"rawDetectedBy": 0,
|
||||||
|
"diagnosticText": "Write complete."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"workerSequence": "2",
|
||||||
|
"workerTimestamp": "2026-01-01T00:00:01.010Z",
|
||||||
|
"gatewayReceiveTimestamp": "2026-01-01T00:00:01.015Z",
|
||||||
|
"hresult": 0,
|
||||||
|
"onWriteComplete": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "MX_EVENT_FAMILY_OPERATION_COMPLETE",
|
||||||
|
"sessionId": "session-fixture",
|
||||||
|
"serverHandle": 12,
|
||||||
|
"itemHandle": 34,
|
||||||
|
"value": {
|
||||||
|
"dataType": "MX_DATA_TYPE_STRING",
|
||||||
|
"variantType": "VT_BSTR",
|
||||||
|
"stringValue": "operation-complete"
|
||||||
|
},
|
||||||
|
"quality": 192,
|
||||||
|
"sourceTimestamp": "2026-01-01T00:00:02Z",
|
||||||
|
"statuses": [
|
||||||
|
{
|
||||||
|
"success": 1,
|
||||||
|
"category": "MX_STATUS_CATEGORY_OK",
|
||||||
|
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_NMX",
|
||||||
|
"detail": 0,
|
||||||
|
"rawCategory": 0,
|
||||||
|
"rawDetectedBy": 0,
|
||||||
|
"diagnosticText": "Operation complete."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"workerSequence": "3",
|
||||||
|
"workerTimestamp": "2026-01-01T00:00:02.010Z",
|
||||||
|
"gatewayReceiveTimestamp": "2026-01-01T00:00:02.015Z",
|
||||||
|
"operationComplete": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"family": "MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE",
|
||||||
|
"sessionId": "session-fixture",
|
||||||
|
"serverHandle": 12,
|
||||||
|
"itemHandle": 34,
|
||||||
|
"value": {
|
||||||
|
"dataType": "MX_DATA_TYPE_FLOAT",
|
||||||
|
"arrayValue": {
|
||||||
|
"elementDataType": "MX_DATA_TYPE_FLOAT",
|
||||||
|
"variantType": "VT_ARRAY|VT_R4",
|
||||||
|
"dimensions": [
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"floatValues": {
|
||||||
|
"values": [
|
||||||
|
1.5,
|
||||||
|
2.5
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality": 192,
|
||||||
|
"sourceTimestamp": "2026-01-01T00:00:03Z",
|
||||||
|
"statuses": [
|
||||||
|
{
|
||||||
|
"success": 1,
|
||||||
|
"category": "MX_STATUS_CATEGORY_OK",
|
||||||
|
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX",
|
||||||
|
"detail": 0,
|
||||||
|
"rawCategory": 0,
|
||||||
|
"rawDetectedBy": 0,
|
||||||
|
"diagnosticText": "Buffered data delivered."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"workerSequence": "4",
|
||||||
|
"workerTimestamp": "2026-01-01T00:00:03.010Z",
|
||||||
|
"gatewayReceiveTimestamp": "2026-01-01T00:00:03.015Z",
|
||||||
|
"onBufferedDataChange": {
|
||||||
|
"dataType": "MX_DATA_TYPE_FLOAT",
|
||||||
|
"qualityValues": {
|
||||||
|
"elementDataType": "MX_DATA_TYPE_INTEGER",
|
||||||
|
"variantType": "VT_ARRAY|VT_I4",
|
||||||
|
"dimensions": [
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"int32Values": {
|
||||||
|
"values": [
|
||||||
|
192,
|
||||||
|
192
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timestampValues": {
|
||||||
|
"elementDataType": "MX_DATA_TYPE_TIME",
|
||||||
|
"variantType": "VT_ARRAY|VT_DATE",
|
||||||
|
"dimensions": [
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"timestampValues": {
|
||||||
|
"values": [
|
||||||
|
"2026-01-01T00:00:02Z",
|
||||||
|
"2026-01-01T00:00:03Z"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rawDataType": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"fixtureSet": "mxaccess-gateway-client-behavior",
|
||||||
|
"contractName": "mxaccess-gateway",
|
||||||
|
"gatewayProtocolVersion": 1,
|
||||||
|
"workerProtocolVersion": 1,
|
||||||
|
"protoInputManifest": "clients/proto/proto-inputs.json",
|
||||||
|
"fixtures": [
|
||||||
|
{
|
||||||
|
"id": "command-reply.register.ok",
|
||||||
|
"category": "command_replies",
|
||||||
|
"messageType": "mxaccess_gateway.v1.MxCommandReply",
|
||||||
|
"path": "command-replies/register.ok.reply.json",
|
||||||
|
"expectation": "Successful command replies preserve protocol status, HRESULT, return value, status arrays, and method-specific output."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "command-reply.write.mxaccess-failure",
|
||||||
|
"category": "command_replies",
|
||||||
|
"messageType": "mxaccess_gateway.v1.MxCommandReply",
|
||||||
|
"path": "command-replies/write.mxaccess-failure.reply.json",
|
||||||
|
"expectation": "MXAccess failures are data-bearing replies with HRESULT and status details, not transport failures."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "event-stream.session-ordered",
|
||||||
|
"category": "event_streams",
|
||||||
|
"messageType": "mxaccess_gateway.v1.MxEvent",
|
||||||
|
"path": "event-streams/session-event-stream.json",
|
||||||
|
"expectation": "Clients preserve per-session event order and event family bodies exactly as emitted."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "values.conversion-cases",
|
||||||
|
"category": "value_conversion",
|
||||||
|
"messageType": "mxaccess_gateway.v1.MxValue",
|
||||||
|
"path": "values/value-conversion-cases.json",
|
||||||
|
"expectation": "Clients expose typed projections and keep raw fallback metadata when conversion is incomplete."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "statuses.conversion-cases",
|
||||||
|
"category": "status_conversion",
|
||||||
|
"messageType": "mxaccess_gateway.v1.MxStatusProxy",
|
||||||
|
"path": "statuses/status-conversion-cases.json",
|
||||||
|
"expectation": "Clients preserve every MXSTATUS_PROXY field, including raw category/source values."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "auth.error-cases",
|
||||||
|
"category": "auth_errors",
|
||||||
|
"messageType": "client_behavior.v1.AuthErrorCase",
|
||||||
|
"path": "auth/auth-error-cases.json",
|
||||||
|
"expectation": "Clients map authentication and authorization failures distinctly and redact credentials."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "timeout-cancel.expected-behavior",
|
||||||
|
"category": "timeout_cancel",
|
||||||
|
"messageType": "client_behavior.v1.TimeoutCancelCase",
|
||||||
|
"path": "timeout-cancel/timeout-cancel-cases.json",
|
||||||
|
"expectation": "Client cancellation stops waiting locally but does not imply an in-flight MXAccess COM call was aborted."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "ok.responding-lmx",
|
||||||
|
"status": {
|
||||||
|
"success": 1,
|
||||||
|
"category": "MX_STATUS_CATEGORY_OK",
|
||||||
|
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX",
|
||||||
|
"detail": 0,
|
||||||
|
"rawCategory": 0,
|
||||||
|
"rawDetectedBy": 0,
|
||||||
|
"diagnosticText": "OK"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "security-error.requesting-lmx",
|
||||||
|
"status": {
|
||||||
|
"success": 0,
|
||||||
|
"category": "MX_STATUS_CATEGORY_SECURITY_ERROR",
|
||||||
|
"detectedBy": "MX_STATUS_SOURCE_REQUESTING_LMX",
|
||||||
|
"detail": 401,
|
||||||
|
"rawCategory": 8,
|
||||||
|
"rawDetectedBy": 2,
|
||||||
|
"diagnosticText": "Requesting LMX denied the secured operation."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "raw-unknown-category",
|
||||||
|
"status": {
|
||||||
|
"success": 0,
|
||||||
|
"category": "MX_STATUS_CATEGORY_UNKNOWN",
|
||||||
|
"detectedBy": "MX_STATUS_SOURCE_UNKNOWN",
|
||||||
|
"detail": 65535,
|
||||||
|
"rawCategory": 99,
|
||||||
|
"rawDetectedBy": 77,
|
||||||
|
"diagnosticText": "Unknown native MXSTATUS_PROXY fields are preserved."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "unary-deadline-exceeded",
|
||||||
|
"operation": "Invoke",
|
||||||
|
"clientDeadline": "2s",
|
||||||
|
"grpcStatusCode": "DEADLINE_EXCEEDED",
|
||||||
|
"clientErrorCategory": "TimeoutError",
|
||||||
|
"gatewayWaitBehavior": "stops_waiting_for_reply",
|
||||||
|
"workerCommandBehavior": "continues_until_worker_reply_or_worker_fault",
|
||||||
|
"sessionExpectation": "session_state_is_unknown_until_follow_up_status_or_close",
|
||||||
|
"expectedClientAction": "issue GetSessionState or CloseSession before reusing handles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "stream-cancel",
|
||||||
|
"operation": "StreamEvents",
|
||||||
|
"clientDeadline": "5s",
|
||||||
|
"grpcStatusCode": "CANCELLED",
|
||||||
|
"clientErrorCategory": "CancelledError",
|
||||||
|
"gatewayWaitBehavior": "stops_streaming_to_that_call",
|
||||||
|
"workerCommandBehavior": "does_not_cancel_worker_session",
|
||||||
|
"sessionExpectation": "session_remains_ready_if_worker_stays_healthy",
|
||||||
|
"expectedClientAction": "open a new StreamEvents call with the last observed worker sequence"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "bool.true",
|
||||||
|
"expectedKind": "boolValue",
|
||||||
|
"value": {
|
||||||
|
"dataType": "MX_DATA_TYPE_BOOLEAN",
|
||||||
|
"variantType": "VT_BOOL",
|
||||||
|
"boolValue": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "int64.large",
|
||||||
|
"expectedKind": "int64Value",
|
||||||
|
"value": {
|
||||||
|
"dataType": "MX_DATA_TYPE_INTEGER",
|
||||||
|
"variantType": "VT_I8",
|
||||||
|
"int64Value": "9223372036854770000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "timestamp.utc",
|
||||||
|
"expectedKind": "timestampValue",
|
||||||
|
"value": {
|
||||||
|
"dataType": "MX_DATA_TYPE_TIME",
|
||||||
|
"variantType": "VT_DATE",
|
||||||
|
"timestampValue": "2026-01-01T00:00:04Z"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "string-array",
|
||||||
|
"expectedKind": "arrayValue",
|
||||||
|
"value": {
|
||||||
|
"dataType": "MX_DATA_TYPE_STRING",
|
||||||
|
"arrayValue": {
|
||||||
|
"elementDataType": "MX_DATA_TYPE_STRING",
|
||||||
|
"variantType": "VT_ARRAY|VT_BSTR",
|
||||||
|
"dimensions": [
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"stringValues": {
|
||||||
|
"values": [
|
||||||
|
"alpha",
|
||||||
|
"beta"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "raw-fallback.variant",
|
||||||
|
"expectedKind": "rawValue",
|
||||||
|
"value": {
|
||||||
|
"dataType": "MX_DATA_TYPE_UNKNOWN",
|
||||||
|
"variantType": "VT_RECORD",
|
||||||
|
"rawDiagnostic": "No lossless typed projection exists for this VARIANT.",
|
||||||
|
"rawDataType": 32767,
|
||||||
|
"rawValue": "AQIDBAU="
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "raw-array-fallback",
|
||||||
|
"expectedKind": "arrayValue",
|
||||||
|
"value": {
|
||||||
|
"dataType": "MX_DATA_TYPE_UNKNOWN",
|
||||||
|
"arrayValue": {
|
||||||
|
"elementDataType": "MX_DATA_TYPE_UNKNOWN",
|
||||||
|
"variantType": "VT_ARRAY|VT_VARIANT",
|
||||||
|
"dimensions": [
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"rawDiagnostic": "Array elements contain mixed VARIANT types.",
|
||||||
|
"rawElementDataType": 32767,
|
||||||
|
"rawValues": {
|
||||||
|
"values": [
|
||||||
|
"AAE=",
|
||||||
|
"AgM="
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
],
|
],
|
||||||
"descriptorSet": "clients/proto/descriptors/mxaccessgw-client-v1.protoset",
|
"descriptorSet": "clients/proto/descriptors/mxaccessgw-client-v1.protoset",
|
||||||
"fixtureRoot": "clients/proto/fixtures/golden",
|
"fixtureRoot": "clients/proto/fixtures/golden",
|
||||||
|
"behaviorFixtureRoot": "clients/proto/fixtures/behavior",
|
||||||
"generatedOutputs": {
|
"generatedOutputs": {
|
||||||
"dotnet": "clients/dotnet/generated",
|
"dotnet": "clients/dotnet/generated",
|
||||||
"go": "clients/go/internal/generated",
|
"go": "clients/go/internal/generated",
|
||||||
|
|||||||
Generated
+1308
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
[package]
|
||||||
|
name = "mxgateway-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = ["crates/mxgw-cli"]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
edition = "2021"
|
||||||
|
version = "0.1.0"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
clap = { version = "4.5.53", features = ["derive"] }
|
||||||
|
prost = "0.13.5"
|
||||||
|
prost-types = "0.13.5"
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.145"
|
||||||
|
thiserror = "2.0.17"
|
||||||
|
tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] }
|
||||||
|
tonic = { version = "0.13.1", features = ["transport"] }
|
||||||
|
tonic-build = "0.13.1"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
prost = { workspace = true }
|
||||||
|
prost-types = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tonic = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tonic-build = { workspace = true }
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Rust Client Workspace
|
||||||
|
|
||||||
|
The Rust client workspace contains the MXAccess Gateway client library, a
|
||||||
|
test CLI, and scaffold tests for generated contract wiring. The library uses
|
||||||
|
the shared protobuf inputs documented in
|
||||||
|
`../../docs/client-proto-generation.md` so the Rust bindings compile against
|
||||||
|
the same public gateway and worker contracts as the server.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
clients/rust/
|
||||||
|
Cargo.toml
|
||||||
|
build.rs
|
||||||
|
src/
|
||||||
|
tests/
|
||||||
|
crates/mxgw-cli/
|
||||||
|
```
|
||||||
|
|
||||||
|
`build.rs` reads the `.proto` files from
|
||||||
|
`../../src/MxGateway.Contracts/Protos` and generates `tonic`/`prost` bindings
|
||||||
|
into Cargo build output. `src/generated.rs` declares the Rust modules that
|
||||||
|
include those generated files. `src/generated` remains reserved for checked-in
|
||||||
|
generator output if the crate later changes to source-tree generation.
|
||||||
|
|
||||||
|
## Build And Test
|
||||||
|
|
||||||
|
Run the Rust workspace checks from `clients/rust`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo fmt --all --check
|
||||||
|
cargo test --workspace
|
||||||
|
cargo check --workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
The build script uses `protoc` from `PATH` or the Windows path recorded in
|
||||||
|
`../../docs/toolchain-links.md`.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
The scaffold CLI exposes version information:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo run -p mxgw-cli -- version --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional commands are implemented with the client/session wrapper work.
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Client Proto Generation](../../docs/client-proto-generation.md)
|
||||||
|
- [Rust Client Detailed Design](../../docs/clients-rust-design.md)
|
||||||
|
- [Rust Style Guide](../../docs/style-guides/RustStyleGuide.md)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
configure_protoc();
|
||||||
|
|
||||||
|
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?);
|
||||||
|
let repo_root = manifest_dir
|
||||||
|
.parent()
|
||||||
|
.and_then(Path::parent)
|
||||||
|
.ok_or("clients/rust must live two levels below the repository root")?;
|
||||||
|
let proto_root = repo_root.join("src/MxGateway.Contracts/Protos");
|
||||||
|
let gateway_proto = proto_root.join("mxaccess_gateway.proto");
|
||||||
|
let worker_proto = proto_root.join("mxaccess_worker.proto");
|
||||||
|
let descriptor_path = PathBuf::from(env::var("OUT_DIR")?).join("mxaccessgw-client-v1.protoset");
|
||||||
|
|
||||||
|
println!("cargo:rerun-if-changed={}", gateway_proto.display());
|
||||||
|
println!("cargo:rerun-if-changed={}", worker_proto.display());
|
||||||
|
|
||||||
|
tonic_build::configure()
|
||||||
|
.build_server(false)
|
||||||
|
.build_client(true)
|
||||||
|
.file_descriptor_set_path(descriptor_path)
|
||||||
|
.compile_protos(
|
||||||
|
&[gateway_proto.as_path(), worker_proto.as_path()],
|
||||||
|
&[proto_root.as_path()],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_protoc() {
|
||||||
|
if env::var_os("PROTOC").is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for candidate in protoc_candidates() {
|
||||||
|
if candidate.is_file() {
|
||||||
|
env::set_var("PROTOC", candidate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protoc_candidates() -> Vec<PathBuf> {
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
|
||||||
|
if cfg!(windows) {
|
||||||
|
if let Some(local_app_data) = env::var_os("LOCALAPPDATA") {
|
||||||
|
candidates.push(PathBuf::from(local_app_data).join(
|
||||||
|
"Microsoft/WinGet/Packages/Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe/bin/protoc.exe",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push(PathBuf::from("protoc"));
|
||||||
|
candidates
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "mxgw-cli"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "mxgw"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { workspace = true }
|
||||||
|
mxgateway-client = { path = "../.." }
|
||||||
|
serde_json = { workspace = true }
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use mxgateway_client::{CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(name = "mxgw")]
|
||||||
|
#[command(about = "MXAccess Gateway Rust test CLI")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Command,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
enum Command {
|
||||||
|
Version {
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
run(cli);
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(cli: Cli) {
|
||||||
|
match cli.command {
|
||||||
|
Command::Version { json } => print_version(json),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_version(use_json: bool) {
|
||||||
|
if use_json {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
json!({
|
||||||
|
"clientVersion": CLIENT_VERSION,
|
||||||
|
"gatewayProtocolVersion": GATEWAY_PROTOCOL_VERSION,
|
||||||
|
"workerProtocolVersion": WORKER_PROTOCOL_VERSION,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("mxgw {CLIENT_VERSION}");
|
||||||
|
println!("gateway protocol {GATEWAY_PROTOCOL_VERSION}");
|
||||||
|
println!("worker protocol {WORKER_PROTOCOL_VERSION}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
use super::Cli;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_version_json_command() {
|
||||||
|
let parsed = Cli::try_parse_from(["mxgw", "version", "--json"]);
|
||||||
|
assert!(parsed.is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// API key wrapper that avoids exposing raw credentials in formatted output.
|
||||||
|
#[derive(Clone, Eq, PartialEq)]
|
||||||
|
pub struct ApiKey(String);
|
||||||
|
|
||||||
|
impl ApiKey {
|
||||||
|
pub fn new(value: impl Into<String>) -> Self {
|
||||||
|
Self(value.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expose_secret(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for ApiKey {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_tuple("ApiKey")
|
||||||
|
.field(&"<redacted>")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ApiKey {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str("<redacted>")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
use tonic::transport::Channel;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::generated::mxaccess_gateway::v1::mx_access_gateway_client::MxAccessGatewayClient;
|
||||||
|
use crate::options::ClientOptions;
|
||||||
|
|
||||||
|
/// Thin owner for the generated gateway client.
|
||||||
|
pub struct GatewayClient {
|
||||||
|
inner: MxAccessGatewayClient<Channel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GatewayClient {
|
||||||
|
pub async fn connect(options: ClientOptions) -> Result<Self, Error> {
|
||||||
|
let endpoint = Channel::from_shared(options.endpoint().to_owned()).map_err(|source| {
|
||||||
|
Error::InvalidEndpoint {
|
||||||
|
endpoint: options.endpoint().to_owned(),
|
||||||
|
detail: source.to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let channel = endpoint.connect().await?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
inner: MxAccessGatewayClient::new(channel),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_inner(self) -> MxAccessGatewayClient<Channel> {
|
||||||
|
self.inner
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
use thiserror::Error as ThisError;
|
||||||
|
|
||||||
|
#[derive(Debug, ThisError)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("invalid gateway endpoint `{endpoint}`: {detail}")]
|
||||||
|
InvalidEndpoint { endpoint: String, detail: String },
|
||||||
|
|
||||||
|
#[error("gateway transport error: {0}")]
|
||||||
|
Transport(#[from] tonic::transport::Error),
|
||||||
|
|
||||||
|
#[error("gateway status error: {0}")]
|
||||||
|
Status(#[from] tonic::Status),
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
pub mod mxaccess_gateway {
|
||||||
|
pub mod v1 {
|
||||||
|
#![allow(clippy::large_enum_variant)]
|
||||||
|
|
||||||
|
tonic::include_proto!("mxaccess_gateway.v1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod mxaccess_worker {
|
||||||
|
pub mod v1 {
|
||||||
|
#![allow(clippy::large_enum_variant)]
|
||||||
|
|
||||||
|
tonic::include_proto!("mxaccess_worker.v1");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//! Rust client scaffold for MXAccess Gateway.
|
||||||
|
//!
|
||||||
|
//! The crate compiles generated `tonic` bindings from the shared gateway
|
||||||
|
//! protobuf contracts and exposes a small handwritten surface for future client
|
||||||
|
//! implementation work.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod client;
|
||||||
|
pub mod error;
|
||||||
|
pub mod generated;
|
||||||
|
pub mod options;
|
||||||
|
pub mod session;
|
||||||
|
pub mod value;
|
||||||
|
pub mod version;
|
||||||
|
|
||||||
|
pub use auth::ApiKey;
|
||||||
|
pub use client::GatewayClient;
|
||||||
|
pub use error::Error;
|
||||||
|
pub use options::ClientOptions;
|
||||||
|
pub use session::Session;
|
||||||
|
pub use version::{CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use crate::auth::ApiKey;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ClientOptions {
|
||||||
|
endpoint: String,
|
||||||
|
api_key: Option<ApiKey>,
|
||||||
|
plaintext: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientOptions {
|
||||||
|
pub fn new(endpoint: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
endpoint: endpoint.into(),
|
||||||
|
api_key: None,
|
||||||
|
plaintext: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_api_key(mut self, api_key: ApiKey) -> Self {
|
||||||
|
self.api_key = Some(api_key);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn endpoint(&self) -> &str {
|
||||||
|
&self.endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn api_key(&self) -> Option<&ApiKey> {
|
||||||
|
self.api_key.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn plaintext(&self) -> bool {
|
||||||
|
self.plaintext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ClientOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new("http://127.0.0.1:5000")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for ClientOptions {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("ClientOptions")
|
||||||
|
.field("endpoint", &self.endpoint)
|
||||||
|
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
||||||
|
.field("plaintext", &self.plaintext)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/// Session identifier returned by the gateway.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Session {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub fn new(id: impl Into<String>) -> Self {
|
||||||
|
Self { id: id.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use crate::generated::mxaccess_gateway::v1::MxValue;
|
||||||
|
|
||||||
|
pub fn int32_value(value: i32) -> MxValue {
|
||||||
|
MxValue {
|
||||||
|
data_type: crate::generated::mxaccess_gateway::v1::MxDataType::Integer as i32,
|
||||||
|
kind: Some(crate::generated::mxaccess_gateway::v1::mx_value::Kind::Int32Value(value)),
|
||||||
|
..MxValue::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub const CLIENT_VERSION: &str = "0.1.0-dev";
|
||||||
|
pub const GATEWAY_PROTOCOL_VERSION: u32 = 1;
|
||||||
|
pub const WORKER_PROTOCOL_VERSION: u32 = 1;
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use mxgateway_client::generated::mxaccess_gateway::v1::{
|
||||||
|
mx_command, mx_value, MxCommand, MxCommandKind, MxCommandRequest, MxDataType, MxEvent,
|
||||||
|
MxEventFamily, MxValue, OpenSessionReply, ProtocolStatusCode, RegisterCommand,
|
||||||
|
};
|
||||||
|
use mxgateway_client::{GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_golden_fixtures_are_available() {
|
||||||
|
for fixture_name in [
|
||||||
|
"open-session-reply.ok.json",
|
||||||
|
"register-command-request.json",
|
||||||
|
"on-data-change-event.json",
|
||||||
|
] {
|
||||||
|
let fixture = read_fixture(fixture_name);
|
||||||
|
assert!(
|
||||||
|
fixture.is_object(),
|
||||||
|
"{fixture_name} must remain a protobuf JSON object"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_session_fixture_matches_protocol_versions() {
|
||||||
|
let fixture = read_fixture("open-session-reply.ok.json");
|
||||||
|
let reply = OpenSessionReply {
|
||||||
|
session_id: string_field(&fixture, "sessionId"),
|
||||||
|
backend_name: string_field(&fixture, "backendName"),
|
||||||
|
worker_process_id: i32_field(&fixture, "workerProcessId"),
|
||||||
|
worker_protocol_version: u32_field(&fixture, "workerProtocolVersion"),
|
||||||
|
gateway_protocol_version: u32_field(&fixture, "gatewayProtocolVersion"),
|
||||||
|
protocol_status: Some(
|
||||||
|
mxgateway_client::generated::mxaccess_gateway::v1::ProtocolStatus {
|
||||||
|
code: ProtocolStatusCode::Ok as i32,
|
||||||
|
message: string_field(&fixture["protocolStatus"], "message"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
..OpenSessionReply::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(reply.gateway_protocol_version, GATEWAY_PROTOCOL_VERSION);
|
||||||
|
assert_eq!(reply.worker_protocol_version, WORKER_PROTOCOL_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn register_fixture_can_build_generated_request() {
|
||||||
|
let fixture = read_fixture("register-command-request.json");
|
||||||
|
let command = &fixture["command"];
|
||||||
|
let request = MxCommandRequest {
|
||||||
|
session_id: string_field(&fixture, "sessionId"),
|
||||||
|
client_correlation_id: string_field(&fixture, "clientCorrelationId"),
|
||||||
|
command: Some(MxCommand {
|
||||||
|
kind: MxCommandKind::Register as i32,
|
||||||
|
payload: Some(mx_command::Payload::Register(RegisterCommand {
|
||||||
|
client_name: string_field(&command["register"], "clientName"),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(request.session_id, "session-fixture");
|
||||||
|
assert_eq!(
|
||||||
|
request.command.unwrap().kind,
|
||||||
|
MxCommandKind::Register as i32
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn on_data_change_fixture_can_build_generated_event() {
|
||||||
|
let fixture = read_fixture("on-data-change-event.json");
|
||||||
|
let event = MxEvent {
|
||||||
|
family: MxEventFamily::OnDataChange as i32,
|
||||||
|
session_id: string_field(&fixture, "sessionId"),
|
||||||
|
server_handle: i32_field(&fixture, "serverHandle"),
|
||||||
|
item_handle: i32_field(&fixture, "itemHandle"),
|
||||||
|
value: Some(MxValue {
|
||||||
|
data_type: MxDataType::Integer as i32,
|
||||||
|
variant_type: string_field(&fixture["value"], "variantType"),
|
||||||
|
kind: Some(mx_value::Kind::Int32Value(i32_field(
|
||||||
|
&fixture["value"],
|
||||||
|
"int32Value",
|
||||||
|
))),
|
||||||
|
..MxValue::default()
|
||||||
|
}),
|
||||||
|
quality: i32_field(&fixture, "quality"),
|
||||||
|
worker_sequence: u64_field(&fixture, "workerSequence"),
|
||||||
|
..MxEvent::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(event.family, MxEventFamily::OnDataChange as i32);
|
||||||
|
assert_eq!(event.value.unwrap().data_type, MxDataType::Integer as i32);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_fixture(name: &str) -> Value {
|
||||||
|
let path = fixture_root().join(name);
|
||||||
|
let data = fs::read_to_string(&path).unwrap_or_else(|error| {
|
||||||
|
panic!("failed to read fixture {}: {error}", path.display());
|
||||||
|
});
|
||||||
|
|
||||||
|
serde_json::from_str(&data).unwrap_or_else(|error| {
|
||||||
|
panic!("failed to parse fixture {}: {error}", path.display());
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture_root() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../proto/fixtures/golden")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_field(value: &Value, name: &str) -> String {
|
||||||
|
value[name]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| panic!("missing string field {name}"))
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn i32_field(value: &Value, name: &str) -> i32 {
|
||||||
|
value[name]
|
||||||
|
.as_i64()
|
||||||
|
.unwrap_or_else(|| panic!("missing i32 field {name}"))
|
||||||
|
.try_into()
|
||||||
|
.unwrap_or_else(|_| panic!("field {name} does not fit in i32"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u32_field(value: &Value, name: &str) -> u32 {
|
||||||
|
value[name]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap_or_else(|| panic!("missing u32 field {name}"))
|
||||||
|
.try_into()
|
||||||
|
.unwrap_or_else(|_| panic!("field {name} does not fit in u32"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u64_field(value: &Value, name: &str) -> u64 {
|
||||||
|
if let Some(number) = value[name].as_u64() {
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
value[name]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_else(|| panic!("missing u64 field {name}"))
|
||||||
|
.parse()
|
||||||
|
.unwrap_or_else(|_| panic!("field {name} does not parse as u64"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Client Behavior Fixtures
|
||||||
|
|
||||||
|
Client behavior fixtures define the shared expectations used by the official
|
||||||
|
.NET, Go, Rust, Python, and Java clients. They keep wrapper behavior aligned
|
||||||
|
while each language exposes idiomatic APIs over the same protobuf contract.
|
||||||
|
|
||||||
|
## Fixture Set
|
||||||
|
|
||||||
|
The fixture manifest is `clients/proto/fixtures/behavior/manifest.json`.
|
||||||
|
`clients/proto/proto-inputs.json` references the fixture root through
|
||||||
|
`behaviorFixtureRoot` so generators and client test projects can discover the
|
||||||
|
same files they use for descriptor inputs.
|
||||||
|
|
||||||
|
The fixture set contains:
|
||||||
|
|
||||||
|
- command reply protobuf JSON,
|
||||||
|
- ordered event stream protobuf JSON samples,
|
||||||
|
- `MxValue` conversion case sets,
|
||||||
|
- `MxStatusProxy` conversion case sets,
|
||||||
|
- authentication and authorization error expectations,
|
||||||
|
- timeout and cancellation behavior expectations.
|
||||||
|
|
||||||
|
Protobuf message fixtures use protobuf JSON field names and enum values. Files
|
||||||
|
that describe client wrapper behavior use explicit JSON fields instead of a
|
||||||
|
proto message because those expectations apply above the generated transport
|
||||||
|
types.
|
||||||
|
|
||||||
|
## Command Replies
|
||||||
|
|
||||||
|
Command reply fixtures live in
|
||||||
|
`clients/proto/fixtures/behavior/command-replies/`. They parse as
|
||||||
|
`mxaccess_gateway.v1.MxCommandReply`.
|
||||||
|
|
||||||
|
Clients use these fixtures to verify that successful and failed MXAccess
|
||||||
|
commands both carry the full reply details:
|
||||||
|
|
||||||
|
- `protocolStatus`,
|
||||||
|
- `hresult`,
|
||||||
|
- `returnValue`,
|
||||||
|
- repeated `statuses`,
|
||||||
|
- method-specific reply payloads when MXAccess returns out parameters.
|
||||||
|
|
||||||
|
MXAccess failures remain command replies when the gateway reached the worker and
|
||||||
|
the worker captured HRESULT or `MXSTATUS_PROXY` details. Client wrappers should
|
||||||
|
map those replies to rich command errors without discarding the raw reply.
|
||||||
|
|
||||||
|
## Event Streams
|
||||||
|
|
||||||
|
Event stream fixtures live in
|
||||||
|
`clients/proto/fixtures/behavior/event-streams/`. Each file contains an ordered
|
||||||
|
`events` array whose entries parse as `mxaccess_gateway.v1.MxEvent`.
|
||||||
|
|
||||||
|
Clients use these fixtures to verify that stream helpers preserve
|
||||||
|
`workerSequence` order and expose each native event family:
|
||||||
|
|
||||||
|
- `OnDataChange`,
|
||||||
|
- `OnWriteComplete`,
|
||||||
|
- `OperationComplete`,
|
||||||
|
- `OnBufferedDataChange`.
|
||||||
|
|
||||||
|
Wrappers must not reorder, coalesce, or drop events while reading the fixture.
|
||||||
|
|
||||||
|
## Value And Status Conversion
|
||||||
|
|
||||||
|
Value fixtures live in `clients/proto/fixtures/behavior/values/`. Each case
|
||||||
|
contains a `value` object that parses as `mxaccess_gateway.v1.MxValue`.
|
||||||
|
|
||||||
|
Status fixtures live in `clients/proto/fixtures/behavior/statuses/`. Each case
|
||||||
|
contains a `status` object that parses as
|
||||||
|
`mxaccess_gateway.v1.MxStatusProxy`.
|
||||||
|
|
||||||
|
Clients use these fixtures to verify typed projections and raw fallback
|
||||||
|
behavior. A language helper may expose native booleans, integers, strings,
|
||||||
|
arrays, and timestamps, but it must keep `rawDiagnostic`, raw data type fields,
|
||||||
|
and raw byte payloads accessible when conversion is incomplete.
|
||||||
|
|
||||||
|
## Auth, Timeout, And Cancel Behavior
|
||||||
|
|
||||||
|
Authentication fixtures live in `clients/proto/fixtures/behavior/auth/`. They
|
||||||
|
separate `UNAUTHENTICATED` from `PERMISSION_DENIED` so clients map missing or
|
||||||
|
invalid credentials differently from missing scopes. Expected output strings
|
||||||
|
contain only redacted credentials.
|
||||||
|
|
||||||
|
Timeout and cancellation fixtures live in
|
||||||
|
`clients/proto/fixtures/behavior/timeout-cancel/`. They document that canceling
|
||||||
|
or timing out a client call stops the client from waiting, but it does not abort
|
||||||
|
an in-flight MXAccess COM call on the worker STA. Clients should follow up with
|
||||||
|
`GetSessionState` or `CloseSession` before reusing handles after an uncertain
|
||||||
|
command timeout.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Run the fixture validation tests after changing the behavior fixture set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
powershell -ExecutionPolicy Bypass -File scripts/validate-client-behavior-fixtures.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
The script runs the focused C# contract tests that parse all protobuf JSON
|
||||||
|
fixtures and validate deterministic wrapper expectation files.
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Client Proto Generation](./client-proto-generation.md)
|
||||||
|
- [Client Libraries Detailed Design](./client-libraries-design.md)
|
||||||
|
- [Protobuf Contracts](./Contracts.md)
|
||||||
@@ -18,6 +18,7 @@ starting `MxGateway.Worker.exe` or loading MXAccess COM. The harness scripts:
|
|||||||
- `WorkerHello` and `WorkerReady` startup,
|
- `WorkerHello` and `WorkerReady` startup,
|
||||||
- command replies with matching correlation ids,
|
- command replies with matching correlation ids,
|
||||||
- ordered `WorkerEvent` frames,
|
- ordered `WorkerEvent` frames,
|
||||||
|
- `WorkerHeartbeat` frames,
|
||||||
- `WorkerFault` frames,
|
- `WorkerFault` frames,
|
||||||
- shutdown acknowledgements,
|
- shutdown acknowledgements,
|
||||||
- malformed protobuf payloads and oversized frame headers,
|
- malformed protobuf payloads and oversized frame headers,
|
||||||
@@ -34,6 +35,45 @@ inside the test.
|
|||||||
`OpenSession`, `Register`, `AddItem`, `Advise`, one streamed `OnDataChange`
|
`OpenSession`, `Register`, `AddItem`, `Advise`, one streamed `OnDataChange`
|
||||||
event, and `CloseSession` without loading MXAccess COM.
|
event, and `CloseSession` without loading MXAccess COM.
|
||||||
|
|
||||||
|
## Live MXAccess Smoke
|
||||||
|
|
||||||
|
`WorkerLiveMxAccessSmokeTests` in `src/MxGateway.IntegrationTests/` composes the
|
||||||
|
real gRPC service, `SessionManager`, `SessionWorkerClientFactory`,
|
||||||
|
`WorkerClient`, `WorkerProcessLauncher`, and `MxGateway.Worker.exe`. It is
|
||||||
|
skipped unless `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1` is set because it creates
|
||||||
|
the installed MXAccess COM object and depends on live provider state.
|
||||||
|
|
||||||
|
The live smoke opens a gateway session, launches the x86 worker, runs
|
||||||
|
`Register`, `AddItem`, and `Advise`, waits a bounded time for one
|
||||||
|
`OnDataChange`, and closes the session in a `finally` block so the worker gets a
|
||||||
|
graceful shutdown request even when a command or event assertion fails.
|
||||||
|
|
||||||
|
Build the worker before running the smoke:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build src/MxGateway.Worker/MxGateway.Worker.csproj -p:Platform=x86
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the smoke explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$env:MXGATEWAY_RUN_LIVE_MXACCESS_TESTS = "1"
|
||||||
|
dotnet test src/MxGateway.IntegrationTests/MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~WorkerLiveMxAccessSmokeTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional live smoke variables:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `MXGATEWAY_LIVE_MXACCESS_WORKER_EXE` | First existing `MxGateway.Worker.exe` under `src/MxGateway.Worker/bin/...` | Worker executable path. Set this when running against a packaged worker or a non-default build output. |
|
||||||
|
| `MXGATEWAY_LIVE_MXACCESS_ITEM` | `TestChildObject.TestInt` | MXAccess item reference used by `AddItem`. |
|
||||||
|
| `MXGATEWAY_LIVE_MXACCESS_CLIENT_NAME` | `MxGateway.IntegrationTests` | Client name passed to `Register`. |
|
||||||
|
| `MXGATEWAY_LIVE_MXACCESS_EVENT_TIMEOUT_SECONDS` | `15` | Maximum wait for the first `OnDataChange`. |
|
||||||
|
|
||||||
|
The test output includes session id, worker process id, command status,
|
||||||
|
HRESULT/status diagnostics, event sequence and handles, close status, and worker
|
||||||
|
stdout/stderr lines emitted during the run.
|
||||||
|
|
||||||
## Focused Commands
|
## Focused Commands
|
||||||
|
|
||||||
Run the fake worker tests after changing gateway worker IPC, session startup, or
|
Run the fake worker tests after changing gateway worker IPC, session startup, or
|
||||||
@@ -43,6 +83,8 @@ event streaming behavior:
|
|||||||
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~FakeWorkerHarnessTests
|
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~FakeWorkerHarnessTests
|
||||||
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~SessionWorkerClientFactoryFakeWorkerTests
|
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~SessionWorkerClientFactoryFakeWorkerTests
|
||||||
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~GatewayEndToEndFakeWorkerSmokeTests
|
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~GatewayEndToEndFakeWorkerSmokeTests
|
||||||
|
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~WorkerClientTests
|
||||||
|
dotnet test src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter FullyQualifiedName~WorkerPipeSessionTests
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the gateway test project after shared gateway test infrastructure changes:
|
Run the gateway test project after shared gateway test infrastructure changes:
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ Language-specific plans:
|
|||||||
Shared generation inputs:
|
Shared generation inputs:
|
||||||
|
|
||||||
- `docs/client-proto-generation.md`
|
- `docs/client-proto-generation.md`
|
||||||
|
- `docs/ClientBehaviorFixtures.md`
|
||||||
- `clients/proto/proto-inputs.json`
|
- `clients/proto/proto-inputs.json`
|
||||||
|
|
||||||
Language style guides:
|
Language style guides:
|
||||||
@@ -310,6 +311,11 @@ CLI output should support JSON for automated tests.
|
|||||||
Unit tests must run without a live gateway. Use fake gRPC services, mock
|
Unit tests must run without a live gateway. Use fake gRPC services, mock
|
||||||
transports, or generated test servers depending on language.
|
transports, or generated test servers depending on language.
|
||||||
|
|
||||||
|
Shared behavior fixtures live in `clients/proto/fixtures/behavior`. Every
|
||||||
|
client should include tests that load the fixture manifest and verify wrapper
|
||||||
|
behavior against the common command reply, event stream, value conversion,
|
||||||
|
status conversion, auth error, and timeout/cancel cases.
|
||||||
|
|
||||||
Required unit test areas:
|
Required unit test areas:
|
||||||
|
|
||||||
- options parsing,
|
- options parsing,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ records:
|
|||||||
- the public and worker source files,
|
- the public and worker source files,
|
||||||
- the descriptor set path,
|
- the descriptor set path,
|
||||||
- golden fixture locations,
|
- golden fixture locations,
|
||||||
|
- behavior fixture locations,
|
||||||
- generated-code output directories for each planned client.
|
- generated-code output directories for each planned client.
|
||||||
|
|
||||||
The source files listed by the manifest are:
|
The source files listed by the manifest are:
|
||||||
@@ -99,10 +100,32 @@ Go clients should generate `mxaccess_gateway.proto` and
|
|||||||
`protoc-gen-go` and `protoc-gen-go-grpc`. Keep generated packages internal
|
`protoc-gen-go` and `protoc-gen-go-grpc`. Keep generated packages internal
|
||||||
unless the wrapper API intentionally exposes raw protobuf messages.
|
unless the wrapper API intentionally exposes raw protobuf messages.
|
||||||
|
|
||||||
Rust clients should use `tonic-build` or the selected protobuf generator from
|
The Go scaffold provides a repo-local generation script:
|
||||||
the Rust client build script, with generated modules placed under
|
|
||||||
`clients/rust/src/generated` or included from the build output according to the
|
```powershell
|
||||||
client crate design.
|
clients/go/generate-proto.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
The script maps both proto files into the internal Go package
|
||||||
|
`gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated` because
|
||||||
|
the source `.proto` files do not carry Go-specific `go_package` options. This
|
||||||
|
keeps language-specific packaging outside the public contract files.
|
||||||
|
|
||||||
|
Rust clients use `tonic-build` from `clients/rust/build.rs`. The build script
|
||||||
|
reads the shared `.proto` files and emits generated `tonic`/`prost` modules
|
||||||
|
into Cargo build output. `clients/rust/src/generated.rs` contains the module
|
||||||
|
declarations that include those generated files. `clients/rust/src/generated`
|
||||||
|
remains reserved for checked-in generator output if the crate later changes to
|
||||||
|
source-tree generation, and handwritten wrapper code stays outside that
|
||||||
|
directory.
|
||||||
|
|
||||||
|
Run the Rust workspace checks from `clients/rust`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo fmt --all --check
|
||||||
|
cargo test --workspace
|
||||||
|
cargo check --workspace
|
||||||
|
```
|
||||||
|
|
||||||
Python clients should use `grpc_tools.protoc` and write generated modules under
|
Python clients should use `grpc_tools.protoc` and write generated modules under
|
||||||
`clients/python/src/mxgateway/generated` so imports stay separate from
|
`clients/python/src/mxgateway/generated` so imports stay separate from
|
||||||
@@ -125,9 +148,30 @@ The fixtures use protobuf JSON field names and enum values. Contract tests parse
|
|||||||
them with the generated C# types so schema drift is caught before client
|
them with the generated C# types so schema drift is caught before client
|
||||||
generation work starts.
|
generation work starts.
|
||||||
|
|
||||||
|
## Behavior Fixtures
|
||||||
|
|
||||||
|
Cross-language behavior fixtures live in
|
||||||
|
`clients/proto/fixtures/behavior`. The manifest
|
||||||
|
`clients/proto/fixtures/behavior/manifest.json` lists command replies, ordered
|
||||||
|
event stream samples, value conversion cases, status conversion cases, auth
|
||||||
|
error expectations, and timeout/cancel expectations.
|
||||||
|
|
||||||
|
The behavior fixtures let each generated client wrapper test the same
|
||||||
|
expectations without a live gateway. Protobuf message fixtures parse with the
|
||||||
|
generated types. Auth and timeout/cancel files describe wrapper behavior above
|
||||||
|
the generated transport layer, including credential redaction and the rule that
|
||||||
|
client cancellation does not abort an in-flight MXAccess COM call.
|
||||||
|
|
||||||
|
Run the focused validation script after changing these fixtures:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scripts/validate-client-behavior-fixtures.ps1
|
||||||
|
```
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [Protobuf Contracts](./Contracts.md)
|
- [Protobuf Contracts](./Contracts.md)
|
||||||
- [Client Libraries Detailed Design](./client-libraries-design.md)
|
- [Client Libraries Detailed Design](./client-libraries-design.md)
|
||||||
|
- [Client Behavior Fixtures](./ClientBehaviorFixtures.md)
|
||||||
- [Client Libraries Implementation Plan](./implementation-plan-clients.md)
|
- [Client Libraries Implementation Plan](./implementation-plan-clients.md)
|
||||||
- [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md)
|
- [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Recommended layout:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
clients/dotnet/
|
clients/dotnet/
|
||||||
|
MxGateway.Client.sln
|
||||||
MxGateway.Client/
|
MxGateway.Client/
|
||||||
MxGateway.Client.csproj
|
MxGateway.Client.csproj
|
||||||
GatewayClient.cs
|
GatewayClient.cs
|
||||||
@@ -41,6 +42,12 @@ Target framework:
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The scaffold uses a project reference to
|
||||||
|
`src/MxGateway.Contracts/MxGateway.Contracts.csproj` for generated protobuf and
|
||||||
|
gRPC types. `clients/dotnet/generated` remains reserved for client-local
|
||||||
|
generator output if the .NET client later needs to decouple from the contracts
|
||||||
|
project.
|
||||||
|
|
||||||
Expected packages:
|
Expected packages:
|
||||||
|
|
||||||
- `Grpc.Net.Client`
|
- `Grpc.Net.Client`
|
||||||
|
|||||||
@@ -175,6 +175,12 @@ Behavior:
|
|||||||
`CloseSession` should be idempotent. Closing an already closed session should
|
`CloseSession` should be idempotent. Closing an already closed session should
|
||||||
return a successful close result with the final known state.
|
return a successful close result with the final known state.
|
||||||
|
|
||||||
|
`WorkerClient.ShutdownAsync` sends `WorkerShutdown`, waits for the worker read,
|
||||||
|
write, and heartbeat loops to stop, and waits for the launched worker process to
|
||||||
|
exit within the same shutdown timeout. If the pipe loops or process exit exceed
|
||||||
|
the timeout, the close operation fails with `ShutdownTimeout`; `GatewaySession`
|
||||||
|
then kills the worker process tree before surfacing the close failure.
|
||||||
|
|
||||||
### Invoke
|
### Invoke
|
||||||
|
|
||||||
`Invoke` forwards one MXAccess command to the worker that owns the session.
|
`Invoke` forwards one MXAccess command to the worker that owns the session.
|
||||||
@@ -515,6 +521,11 @@ It handles:
|
|||||||
The write loop should fail the session if a pipe write fails outside normal
|
The write loop should fail the session if a pipe write fails outside normal
|
||||||
shutdown.
|
shutdown.
|
||||||
|
|
||||||
|
During shutdown the worker client treats `WorkerShutdownAck` as the protocol
|
||||||
|
close signal, but the process handle remains authoritative for process lifetime.
|
||||||
|
The client waits for both the protocol close and process exit before reporting a
|
||||||
|
clean shutdown to `GatewaySession`.
|
||||||
|
|
||||||
## Command Correlation
|
## Command Correlation
|
||||||
|
|
||||||
Each command gets:
|
Each command gets:
|
||||||
|
|||||||
@@ -321,6 +321,13 @@ If COM creation fails, the worker should send a structured fault with:
|
|||||||
when the exception exposes one, and does not send `WorkerReady` after a failed
|
when the exception exposes one, and does not send `WorkerReady` after a failed
|
||||||
COM creation attempt.
|
COM creation attempt.
|
||||||
|
|
||||||
|
After `WorkerReady`, `WorkerPipeSession` continues reading gateway frames for
|
||||||
|
the lifetime of the process. `WorkerCommand` frames are dispatched to
|
||||||
|
`MxAccessStaSession`, replies are written as `WorkerCommandReply`, and queued
|
||||||
|
worker events are drained after command replies. `WorkerShutdown` starts the
|
||||||
|
graceful shutdown path and returns `WorkerShutdownAck` only after the STA
|
||||||
|
cleanup path completes.
|
||||||
|
|
||||||
## Event Sink
|
## Event Sink
|
||||||
|
|
||||||
The worker must subscribe to every public MXAccess event family:
|
The worker must subscribe to every public MXAccess event family:
|
||||||
@@ -618,13 +625,19 @@ Do not drop or coalesce events in v1.
|
|||||||
|
|
||||||
## Heartbeat And Watchdog
|
## Heartbeat And Watchdog
|
||||||
|
|
||||||
The worker heartbeat should prove that:
|
`WorkerPipeSession` starts the heartbeat loop after the gateway validates
|
||||||
|
`WorkerHello` and receives `WorkerReady`. Heartbeats continue until
|
||||||
|
`WorkerShutdown`, cancellation, or a pipe/protocol failure stops the session.
|
||||||
|
The loop uses `WorkerPipeSessionOptions.HeartbeatInterval`; the default matches
|
||||||
|
the gateway worker heartbeat interval.
|
||||||
|
|
||||||
|
The worker heartbeat proves that:
|
||||||
|
|
||||||
- pipe writer is alive,
|
- pipe writer is alive,
|
||||||
- worker host is alive,
|
- worker host is alive,
|
||||||
- STA has recently pumped or completed work.
|
- STA has recently pumped or completed work.
|
||||||
|
|
||||||
Heartbeat payload should include:
|
Heartbeat payload includes:
|
||||||
|
|
||||||
- worker process id,
|
- worker process id,
|
||||||
- session id,
|
- session id,
|
||||||
@@ -635,13 +648,19 @@ Heartbeat payload should include:
|
|||||||
- event sequence,
|
- event sequence,
|
||||||
- current command correlation id if any.
|
- current command correlation id if any.
|
||||||
|
|
||||||
The STA watchdog should warn when:
|
`MxAccessStaSession.CaptureHeartbeat()` reads `StaRuntime.LastActivityUtc` and
|
||||||
|
`StaCommandDispatcher` queue state without touching the raw MXAccess COM object
|
||||||
|
outside the STA. Event queue depth and event sequence are reported as zero until
|
||||||
|
the event queue implementation owns those counters.
|
||||||
|
|
||||||
- one command exceeds its expected duration,
|
The STA watchdog currently emits a `WorkerFault` with
|
||||||
- the STA has not pumped messages within the heartbeat grace period,
|
`WorkerFaultCategory.StaHung` when `LastStaActivityUtc` is older than
|
||||||
- event queue depth remains high.
|
`WorkerPipeSessionOptions.HeartbeatGrace`. The fault includes the current
|
||||||
|
command correlation id when a command is active. Command duration and high event
|
||||||
The worker can report the problem, but the gateway owns the final kill decision.
|
queue depth remain observable through heartbeat fields until dedicated
|
||||||
|
thresholds own those warnings. The worker reports stale STA activity, but the
|
||||||
|
gateway owns the final kill decision through its existing heartbeat and worker
|
||||||
|
lifecycle policy.
|
||||||
|
|
||||||
## Shutdown
|
## Shutdown
|
||||||
|
|
||||||
@@ -663,6 +682,29 @@ Graceful shutdown sequence:
|
|||||||
If shutdown wedges, the gateway kills the process. The worker should be written
|
If shutdown wedges, the gateway kills the process. The worker should be written
|
||||||
so process kill does not corrupt other sessions.
|
so process kill does not corrupt other sessions.
|
||||||
|
|
||||||
|
`MxAccessStaSession.ShutdownGracefullyAsync` implements the current cleanup
|
||||||
|
path. It first calls `StaCommandDispatcher.RequestShutdown()` so new commands
|
||||||
|
are rejected and queued commands that have not started receive
|
||||||
|
`ProtocolStatusCode.WorkerUnavailable`. The command already executing on the
|
||||||
|
STA is allowed to finish until the shutdown grace period expires.
|
||||||
|
|
||||||
|
After command dispatch is closed, cleanup runs on the STA in MXAccess handle
|
||||||
|
order:
|
||||||
|
|
||||||
|
1. one `UnAdvise` call per advised server/item pair,
|
||||||
|
2. `RemoveItem` for active item handles,
|
||||||
|
3. `Unregister` for active server handles,
|
||||||
|
4. event sink detach,
|
||||||
|
5. COM release.
|
||||||
|
|
||||||
|
Each cleanup call is best effort. A failed cleanup operation is recorded as an
|
||||||
|
`MxAccessShutdownFailure`, logged by `WorkerPipeSession`, and does not prevent
|
||||||
|
later cleanup calls from running. A shutdown with cleanup failures still returns
|
||||||
|
`WorkerShutdownAck` with `ProtocolStatusCode.Ok` because the worker reached the
|
||||||
|
controlled release path. If the grace period expires before cleanup can run or
|
||||||
|
finish, the worker reports `WorkerFaultCategory.ShutdownTimeout` when possible
|
||||||
|
and relies on the gateway to kill the process.
|
||||||
|
|
||||||
## Fault Handling
|
## Fault Handling
|
||||||
|
|
||||||
Worker fault categories:
|
Worker fault categories:
|
||||||
@@ -765,6 +807,14 @@ tests. `AddItem` uses `TestChildObject.TestInt` by default and accepts an
|
|||||||
override through `MXGATEWAY_LIVE_MXACCESS_ITEM`; `AddItem2` uses the captured
|
override through `MXGATEWAY_LIVE_MXACCESS_ITEM`; `AddItem2` uses the captured
|
||||||
parity fixture shape `AddItem2("TestInt", "TestChildObject")`.
|
parity fixture shape `AddItem2("TestInt", "TestChildObject")`.
|
||||||
|
|
||||||
|
`WorkerLiveMxAccessSmokeTests` in `src/MxGateway.IntegrationTests/` uses the
|
||||||
|
same opt-in variable for the gateway-to-worker live smoke. It launches the x86
|
||||||
|
worker through `WorkerProcessLauncher`, opens a gateway session, runs
|
||||||
|
`Register`, `AddItem`, and `Advise`, waits for one `OnDataChange`, and closes
|
||||||
|
the session. The smoke accepts `MXGATEWAY_LIVE_MXACCESS_WORKER_EXE` for a
|
||||||
|
non-default worker executable path and
|
||||||
|
`MXGATEWAY_LIVE_MXACCESS_EVENT_TIMEOUT_SECONDS` for the bounded event wait.
|
||||||
|
|
||||||
## Initial Implementation Slice
|
## Initial Implementation Slice
|
||||||
|
|
||||||
The first worker slice should implement:
|
The first worker slice should implement:
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[switch]$NoBuild
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||||
|
$testProject = Join-Path $repoRoot "src/MxGateway.Tests/MxGateway.Tests.csproj"
|
||||||
|
$arguments = @(
|
||||||
|
"test",
|
||||||
|
$testProject,
|
||||||
|
"--filter",
|
||||||
|
"ClientBehaviorFixtureTests"
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($NoBuild) {
|
||||||
|
$arguments += "--no-build"
|
||||||
|
}
|
||||||
|
|
||||||
|
& dotnet @arguments
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Client behavior fixture validation failed with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
@@ -3,10 +3,92 @@ namespace MxGateway.IntegrationTests;
|
|||||||
public static class IntegrationTestEnvironment
|
public static class IntegrationTestEnvironment
|
||||||
{
|
{
|
||||||
public const string LiveMxAccessVariableName = "MXGATEWAY_RUN_LIVE_MXACCESS_TESTS";
|
public const string LiveMxAccessVariableName = "MXGATEWAY_RUN_LIVE_MXACCESS_TESTS";
|
||||||
|
public const string LiveMxAccessWorkerExecutableVariableName = "MXGATEWAY_LIVE_MXACCESS_WORKER_EXE";
|
||||||
|
public const string LiveMxAccessItemVariableName = "MXGATEWAY_LIVE_MXACCESS_ITEM";
|
||||||
|
public const string LiveMxAccessClientNameVariableName = "MXGATEWAY_LIVE_MXACCESS_CLIENT_NAME";
|
||||||
|
public const string LiveMxAccessEventTimeoutSecondsVariableName = "MXGATEWAY_LIVE_MXACCESS_EVENT_TIMEOUT_SECONDS";
|
||||||
|
|
||||||
public static bool LiveMxAccessTestsEnabled =>
|
public static bool LiveMxAccessTestsEnabled =>
|
||||||
string.Equals(
|
string.Equals(
|
||||||
Environment.GetEnvironmentVariable(LiveMxAccessVariableName),
|
Environment.GetEnvironmentVariable(LiveMxAccessVariableName),
|
||||||
"1",
|
"1",
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
|
public static string LiveMxAccessItem =>
|
||||||
|
GetOptionalEnvironmentVariable(
|
||||||
|
LiveMxAccessItemVariableName,
|
||||||
|
"TestChildObject.TestInt");
|
||||||
|
|
||||||
|
public static string LiveMxAccessClientName =>
|
||||||
|
GetOptionalEnvironmentVariable(
|
||||||
|
LiveMxAccessClientNameVariableName,
|
||||||
|
"MxGateway.IntegrationTests");
|
||||||
|
|
||||||
|
public static TimeSpan LiveMxAccessEventTimeout =>
|
||||||
|
TimeSpan.FromSeconds(GetPositiveIntegerEnvironmentVariable(
|
||||||
|
LiveMxAccessEventTimeoutSecondsVariableName,
|
||||||
|
defaultValue: 15));
|
||||||
|
|
||||||
|
public static string ResolveLiveMxAccessWorkerExecutablePath()
|
||||||
|
{
|
||||||
|
string? configuredPath = Environment.GetEnvironmentVariable(LiveMxAccessWorkerExecutableVariableName);
|
||||||
|
if (!string.IsNullOrWhiteSpace(configuredPath))
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(configuredPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
string repositoryRoot = ResolveRepositoryRoot(AppContext.BaseDirectory);
|
||||||
|
string[] candidatePaths =
|
||||||
|
[
|
||||||
|
Path.Combine(repositoryRoot, "src", "MxGateway.Worker", "bin", "x86", "Debug", "net48", "MxGateway.Worker.exe"),
|
||||||
|
Path.Combine(repositoryRoot, "src", "MxGateway.Worker", "bin", "Debug", "net48", "MxGateway.Worker.exe"),
|
||||||
|
Path.Combine(repositoryRoot, "src", "MxGateway.Worker", "bin", "x86", "Release", "net48", "MxGateway.Worker.exe"),
|
||||||
|
Path.Combine(repositoryRoot, "src", "MxGateway.Worker", "bin", "Release", "net48", "MxGateway.Worker.exe"),
|
||||||
|
Path.Combine(repositoryRoot, "src", "MxGateway.Worker", "bin", "x86", "Release", "MxGateway.Worker.exe"),
|
||||||
|
];
|
||||||
|
|
||||||
|
return candidatePaths.FirstOrDefault(File.Exists)
|
||||||
|
?? candidatePaths[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetOptionalEnvironmentVariable(
|
||||||
|
string name,
|
||||||
|
string defaultValue)
|
||||||
|
{
|
||||||
|
string? value = Environment.GetEnvironmentVariable(name);
|
||||||
|
return string.IsNullOrWhiteSpace(value)
|
||||||
|
? defaultValue
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetPositiveIntegerEnvironmentVariable(
|
||||||
|
string name,
|
||||||
|
int defaultValue)
|
||||||
|
{
|
||||||
|
string? value = Environment.GetEnvironmentVariable(name);
|
||||||
|
if (int.TryParse(value, out int parsed) && parsed > 0)
|
||||||
|
{
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ResolveRepositoryRoot(string startDirectory)
|
||||||
|
{
|
||||||
|
DirectoryInfo? directory = new(startDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
if ((Directory.Exists(Path.Combine(directory.FullName, ".git"))
|
||||||
|
|| File.Exists(Path.Combine(directory.FullName, ".git")))
|
||||||
|
&& Directory.Exists(Path.Combine(directory.FullName, "src")))
|
||||||
|
{
|
||||||
|
return directory.FullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Directory.GetCurrentDirectory();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,37 @@ public sealed class IntegrationTestEnvironmentTests
|
|||||||
"MXGATEWAY_RUN_LIVE_MXACCESS_TESTS",
|
"MXGATEWAY_RUN_LIVE_MXACCESS_TESTS",
|
||||||
IntegrationTestEnvironment.LiveMxAccessVariableName);
|
IntegrationTestEnvironment.LiveMxAccessVariableName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LiveMxAccessWorkerExecutable_UsesDocumentedEnvironmentVariable()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
"MXGATEWAY_LIVE_MXACCESS_WORKER_EXE",
|
||||||
|
IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveRepositoryRoot_AcceptsGitWorktreeFile()
|
||||||
|
{
|
||||||
|
string temporaryRoot = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
|
||||||
|
string nestedDirectory = Path.Combine(temporaryRoot, "tests", "bin");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(nestedDirectory);
|
||||||
|
Directory.CreateDirectory(Path.Combine(temporaryRoot, "src"));
|
||||||
|
File.WriteAllText(Path.Combine(temporaryRoot, ".git"), "gitdir: ../.git/worktrees/test");
|
||||||
|
|
||||||
|
string repositoryRoot = IntegrationTestEnvironment.ResolveRepositoryRoot(nestedDirectory);
|
||||||
|
|
||||||
|
Assert.Equal(temporaryRoot, repositoryRoot);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (Directory.Exists(temporaryRoot))
|
||||||
|
{
|
||||||
|
Directory.Delete(temporaryRoot, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace MxGateway.IntegrationTests;
|
||||||
|
|
||||||
|
public sealed class LiveMxAccessFactAttribute : FactAttribute
|
||||||
|
{
|
||||||
|
public LiveMxAccessFactAttribute()
|
||||||
|
{
|
||||||
|
if (!IntegrationTestEnvironment.LiveMxAccessTestsEnabled)
|
||||||
|
{
|
||||||
|
Skip = $"Set {IntegrationTestEnvironment.LiveMxAccessVariableName}=1 to run live MXAccess tests.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\MxGateway.Contracts\MxGateway.Contracts.csproj" />
|
<ProjectReference Include="..\MxGateway.Contracts\MxGateway.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\MxGateway.Server\MxGateway.Server.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,517 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Grpc.Core;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
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.Security.Authorization;
|
||||||
|
using MxGateway.Server.Sessions;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace MxGateway.IntegrationTests;
|
||||||
|
|
||||||
|
public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(15);
|
||||||
|
private static readonly TimeSpan StreamShutdownTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
[LiveMxAccessFact]
|
||||||
|
[Trait("Category", "LiveMxAccess")]
|
||||||
|
public async Task GatewaySession_WithLiveWorker_RegistersAdvisesStreamsDataAndCloses()
|
||||||
|
{
|
||||||
|
string workerExecutablePath = IntegrationTestEnvironment.ResolveLiveMxAccessWorkerExecutablePath();
|
||||||
|
Assert.True(
|
||||||
|
File.Exists(workerExecutablePath),
|
||||||
|
$"Live MXAccess worker executable was not found at {workerExecutablePath}. Build the worker or set {IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName}.");
|
||||||
|
|
||||||
|
TestWorkerProcessFactory processFactory = new(output);
|
||||||
|
await using GatewayServiceFixture fixture = new(workerExecutablePath, processFactory, output);
|
||||||
|
|
||||||
|
string? sessionId = null;
|
||||||
|
RecordingServerStreamWriter<MxEvent>? eventWriter = null;
|
||||||
|
Task? streamTask = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
OpenSessionReply openReply = await fixture.Service.OpenSession(
|
||||||
|
new OpenSessionRequest
|
||||||
|
{
|
||||||
|
ClientSessionName = "live-mxaccess-smoke",
|
||||||
|
ClientCorrelationId = "live-open",
|
||||||
|
CommandTimeout = Duration.FromTimeSpan(CommandTimeout),
|
||||||
|
},
|
||||||
|
new TestServerCallContext()).ConfigureAwait(false);
|
||||||
|
|
||||||
|
sessionId = openReply.SessionId;
|
||||||
|
output.WriteLine($"OpenSession status={openReply.ProtocolStatus.Code} session={sessionId} worker_pid={openReply.WorkerProcessId}");
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code);
|
||||||
|
Assert.True(openReply.WorkerProcessId > 0);
|
||||||
|
|
||||||
|
eventWriter = new RecordingServerStreamWriter<MxEvent>();
|
||||||
|
streamTask = fixture.Service.StreamEvents(
|
||||||
|
new StreamEventsRequest { SessionId = sessionId },
|
||||||
|
eventWriter,
|
||||||
|
new TestServerCallContext());
|
||||||
|
|
||||||
|
MxCommandReply registerReply = await fixture.Service.Invoke(
|
||||||
|
CreateRegisterRequest(sessionId),
|
||||||
|
new TestServerCallContext()).ConfigureAwait(false);
|
||||||
|
LogReply("Register", registerReply);
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, registerReply.ProtocolStatus.Code);
|
||||||
|
Assert.True(registerReply.Register.ServerHandle > 0);
|
||||||
|
|
||||||
|
MxCommandReply addItemReply = await fixture.Service.Invoke(
|
||||||
|
CreateAddItemRequest(sessionId, registerReply.Register.ServerHandle),
|
||||||
|
new TestServerCallContext()).ConfigureAwait(false);
|
||||||
|
LogReply("AddItem", addItemReply);
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code);
|
||||||
|
Assert.True(addItemReply.AddItem.ItemHandle > 0);
|
||||||
|
|
||||||
|
MxCommandReply adviseReply = await fixture.Service.Invoke(
|
||||||
|
CreateAdviseRequest(
|
||||||
|
sessionId,
|
||||||
|
registerReply.Register.ServerHandle,
|
||||||
|
addItemReply.AddItem.ItemHandle),
|
||||||
|
new TestServerCallContext()).ConfigureAwait(false);
|
||||||
|
LogReply("Advise", adviseReply);
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, adviseReply.ProtocolStatus.Code);
|
||||||
|
|
||||||
|
MxEvent dataChange = await eventWriter
|
||||||
|
.WaitForFirstMessageAsync(IntegrationTestEnvironment.LiveMxAccessEventTimeout)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
LogEvent(dataChange);
|
||||||
|
|
||||||
|
Assert.Equal(MxEventFamily.OnDataChange, dataChange.Family);
|
||||||
|
Assert.Equal(sessionId, dataChange.SessionId);
|
||||||
|
Assert.Equal(registerReply.Register.ServerHandle, dataChange.ServerHandle);
|
||||||
|
Assert.Equal(addItemReply.AddItem.ItemHandle, dataChange.ItemHandle);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
await CloseSessionAsync(fixture, sessionId).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamTask is not null)
|
||||||
|
{
|
||||||
|
await streamTask.WaitAsync(StreamShutdownTimeout).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await processFactory.WaitForProcessesAsync(StreamShutdownTimeout).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommandRequest CreateRegisterRequest(string sessionId)
|
||||||
|
{
|
||||||
|
return new MxCommandRequest
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
ClientCorrelationId = "live-register",
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Register,
|
||||||
|
Register = new RegisterCommand
|
||||||
|
{
|
||||||
|
ClientName = IntegrationTestEnvironment.LiveMxAccessClientName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommandRequest CreateAddItemRequest(
|
||||||
|
string sessionId,
|
||||||
|
int serverHandle)
|
||||||
|
{
|
||||||
|
return new MxCommandRequest
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
ClientCorrelationId = "live-add-item",
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItem,
|
||||||
|
AddItem = new AddItemCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemDefinition = IntegrationTestEnvironment.LiveMxAccessItem,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommandRequest CreateAdviseRequest(
|
||||||
|
string sessionId,
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle)
|
||||||
|
{
|
||||||
|
return new MxCommandRequest
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
ClientCorrelationId = "live-advise",
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Advise,
|
||||||
|
Advise = new AdviseCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemHandle = itemHandle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CloseSessionAsync(
|
||||||
|
GatewayServiceFixture fixture,
|
||||||
|
string sessionId)
|
||||||
|
{
|
||||||
|
CloseSessionReply closeReply = await fixture.Service.CloseSession(
|
||||||
|
new CloseSessionRequest
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
ClientCorrelationId = "live-close",
|
||||||
|
},
|
||||||
|
new TestServerCallContext()).ConfigureAwait(false);
|
||||||
|
|
||||||
|
output.WriteLine($"CloseSession status={closeReply.ProtocolStatus.Code} final_state={closeReply.FinalState}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LogReply(
|
||||||
|
string method,
|
||||||
|
MxCommandReply reply)
|
||||||
|
{
|
||||||
|
output.WriteLine(
|
||||||
|
$"{method} status={reply.ProtocolStatus.Code} hresult={reply.Hresult} diagnostic={reply.DiagnosticMessage}");
|
||||||
|
|
||||||
|
foreach (MxStatusProxy status in reply.Statuses)
|
||||||
|
{
|
||||||
|
output.WriteLine(
|
||||||
|
$"{method} mxstatus success={status.Success} category={status.Category} detail={status.Detail} text={status.DiagnosticText}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LogEvent(MxEvent dataChange)
|
||||||
|
{
|
||||||
|
output.WriteLine(
|
||||||
|
$"Event family={dataChange.Family} worker_sequence={dataChange.WorkerSequence} server_handle={dataChange.ServerHandle} item_handle={dataChange.ItemHandle} quality={dataChange.Quality}");
|
||||||
|
output.WriteLine(
|
||||||
|
$"Event value_type={dataChange.Value?.DataType} raw_status={dataChange.RawStatus}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GatewayServiceFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly GatewayMetrics _metrics = new();
|
||||||
|
private readonly SessionRegistry _registry = new();
|
||||||
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
|
|
||||||
|
public GatewayServiceFixture(
|
||||||
|
string workerExecutablePath,
|
||||||
|
IWorkerProcessFactory processFactory,
|
||||||
|
ITestOutputHelper output)
|
||||||
|
{
|
||||||
|
IOptions<GatewayOptions> options = Options.Create(CreateOptions(workerExecutablePath));
|
||||||
|
_loggerFactory = LoggerFactory.Create(builder => builder.AddProvider(new TestOutputLoggerProvider(output)));
|
||||||
|
WorkerProcessLauncher launcher = new(
|
||||||
|
options,
|
||||||
|
processFactory,
|
||||||
|
new WorkerProcessStartedProbe(),
|
||||||
|
_metrics);
|
||||||
|
SessionWorkerClientFactory workerClientFactory = new(
|
||||||
|
launcher,
|
||||||
|
options,
|
||||||
|
_metrics,
|
||||||
|
_loggerFactory);
|
||||||
|
SessionManager sessionManager = new(
|
||||||
|
_registry,
|
||||||
|
workerClientFactory,
|
||||||
|
options,
|
||||||
|
_metrics,
|
||||||
|
logger: _loggerFactory.CreateLogger<SessionManager>());
|
||||||
|
MxAccessGrpcMapper mapper = new();
|
||||||
|
EventStreamService eventStreamService = new(
|
||||||
|
sessionManager,
|
||||||
|
options,
|
||||||
|
mapper,
|
||||||
|
_metrics,
|
||||||
|
_loggerFactory.CreateLogger<EventStreamService>());
|
||||||
|
|
||||||
|
Service = new MxAccessGatewayService(
|
||||||
|
sessionManager,
|
||||||
|
new GatewayRequestIdentityAccessor(),
|
||||||
|
new MxAccessGrpcRequestValidator(),
|
||||||
|
mapper,
|
||||||
|
eventStreamService,
|
||||||
|
_loggerFactory.CreateLogger<MxAccessGatewayService>());
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxAccessGatewayService Service { get; }
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
foreach (GatewaySession session in _registry.Snapshot())
|
||||||
|
{
|
||||||
|
await session.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_loggerFactory.Dispose();
|
||||||
|
_metrics.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GatewayOptions CreateOptions(string workerExecutablePath)
|
||||||
|
{
|
||||||
|
return new GatewayOptions
|
||||||
|
{
|
||||||
|
Worker = new WorkerOptions
|
||||||
|
{
|
||||||
|
ExecutablePath = workerExecutablePath,
|
||||||
|
StartupTimeoutSeconds = 30,
|
||||||
|
ShutdownTimeoutSeconds = 15,
|
||||||
|
HeartbeatIntervalSeconds = 5,
|
||||||
|
HeartbeatGraceSeconds = 15,
|
||||||
|
MaxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes,
|
||||||
|
RequiredArchitecture = WorkerArchitecture.X86,
|
||||||
|
},
|
||||||
|
Sessions = new SessionOptions
|
||||||
|
{
|
||||||
|
DefaultCommandTimeoutSeconds = 15,
|
||||||
|
MaxSessions = 1,
|
||||||
|
},
|
||||||
|
Events = new EventOptions
|
||||||
|
{
|
||||||
|
QueueCapacity = 32,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T>
|
||||||
|
{
|
||||||
|
private readonly object syncRoot = new();
|
||||||
|
private readonly TaskCompletionSource<T> firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private readonly List<T> messages = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<T> Messages
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (syncRoot)
|
||||||
|
{
|
||||||
|
return messages.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public WriteOptions? WriteOptions { get; set; }
|
||||||
|
|
||||||
|
public Task WriteAsync(T message)
|
||||||
|
{
|
||||||
|
lock (syncRoot)
|
||||||
|
{
|
||||||
|
messages.Add(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
firstMessage.TrySetResult(message);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<T> WaitForFirstMessageAsync(TimeSpan timeout)
|
||||||
|
{
|
||||||
|
return await firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestWorkerProcessFactory(ITestOutputHelper output) : IWorkerProcessFactory
|
||||||
|
{
|
||||||
|
private readonly ConcurrentBag<TestWorkerProcess> processes = [];
|
||||||
|
|
||||||
|
public IWorkerProcess Start(ProcessStartInfo startInfo)
|
||||||
|
{
|
||||||
|
startInfo.RedirectStandardError = true;
|
||||||
|
startInfo.RedirectStandardOutput = true;
|
||||||
|
startInfo.UseShellExecute = false;
|
||||||
|
|
||||||
|
Process process = new()
|
||||||
|
{
|
||||||
|
StartInfo = startInfo,
|
||||||
|
EnableRaisingEvents = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
process.OutputDataReceived += (_, args) => WriteWorkerOutput("stdout", args.Data);
|
||||||
|
process.ErrorDataReceived += (_, args) => WriteWorkerOutput("stderr", args.Data);
|
||||||
|
|
||||||
|
if (!process.Start())
|
||||||
|
{
|
||||||
|
process.Dispose();
|
||||||
|
throw new InvalidOperationException("Worker process failed to start.");
|
||||||
|
}
|
||||||
|
|
||||||
|
process.BeginOutputReadLine();
|
||||||
|
process.BeginErrorReadLine();
|
||||||
|
|
||||||
|
TestWorkerProcess workerProcess = new(process);
|
||||||
|
processes.Add(workerProcess);
|
||||||
|
output.WriteLine($"WorkerProcess started pid={workerProcess.Id} path={startInfo.FileName}");
|
||||||
|
|
||||||
|
return workerProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WaitForProcessesAsync(TimeSpan timeout)
|
||||||
|
{
|
||||||
|
foreach (TestWorkerProcess process in processes)
|
||||||
|
{
|
||||||
|
if (process.HasExited)
|
||||||
|
{
|
||||||
|
output.WriteLine($"WorkerProcess exited pid={process.Id} exit_code={process.ExitCode}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
using CancellationTokenSource timeoutCancellation = new(timeout);
|
||||||
|
await process.WaitForExitAsync(timeoutCancellation.Token).ConfigureAwait(false);
|
||||||
|
output.WriteLine($"WorkerProcess exited pid={process.Id} exit_code={process.ExitCode}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteWorkerOutput(
|
||||||
|
string streamName,
|
||||||
|
string? line)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(line))
|
||||||
|
{
|
||||||
|
output.WriteLine($"worker_{streamName}: {line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestWorkerProcess(Process process) : IWorkerProcess
|
||||||
|
{
|
||||||
|
public int Id => process.Id;
|
||||||
|
|
||||||
|
public bool HasExited => process.HasExited;
|
||||||
|
|
||||||
|
public int? ExitCode => process.HasExited ? process.ExitCode : null;
|
||||||
|
|
||||||
|
public async ValueTask WaitForExitAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Kill(bool entireProcessTree)
|
||||||
|
{
|
||||||
|
process.Kill(entireProcessTree);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
process.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestOutputLoggerProvider(ITestOutputHelper output) : ILoggerProvider
|
||||||
|
{
|
||||||
|
public ILogger CreateLogger(string categoryName)
|
||||||
|
{
|
||||||
|
return new TestOutputLogger(output, categoryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestOutputLogger(
|
||||||
|
ITestOutputHelper output,
|
||||||
|
string categoryName) : ILogger
|
||||||
|
{
|
||||||
|
public IDisposable? BeginScope<TState>(TState state)
|
||||||
|
where TState : notnull
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsEnabled(LogLevel logLevel)
|
||||||
|
{
|
||||||
|
return logLevel >= LogLevel.Information;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel,
|
||||||
|
EventId eventId,
|
||||||
|
TState state,
|
||||||
|
Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
if (!IsEnabled(logLevel))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.WriteLine($"{logLevel} {categoryName}: {formatter(state, exception)}");
|
||||||
|
if (exception is not null)
|
||||||
|
{
|
||||||
|
output.WriteLine(exception.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -227,6 +227,7 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await WaitForBackgroundTasksAsync(timeoutCts.Token).ConfigureAwait(false);
|
await WaitForBackgroundTasksAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||||
|
await WaitForProcessExitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||||
MarkClosed("shutdown");
|
MarkClosed("shutdown");
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
@@ -717,6 +718,17 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false);
|
await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task WaitForProcessExitAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
WorkerProcessHandle? processHandle = _connection.ProcessHandle;
|
||||||
|
if (processHandle is null || processHandle.Process.HasExited)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await processHandle.Process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
private void ThrowIfDisposed()
|
private void ThrowIfDisposed()
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Google.Protobuf;
|
||||||
|
using MxGateway.Contracts;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.Contracts;
|
||||||
|
|
||||||
|
public sealed class ClientBehaviorFixtureTests
|
||||||
|
{
|
||||||
|
private static readonly JsonParser ProtobufJsonParser = new(JsonParser.Settings.Default);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BehaviorManifest_DeclaresCurrentProtocolVersionsAndExistingFixtures()
|
||||||
|
{
|
||||||
|
using JsonDocument manifest = LoadBehaviorManifest();
|
||||||
|
JsonElement root = manifest.RootElement;
|
||||||
|
|
||||||
|
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
|
||||||
|
Assert.Equal("mxaccess-gateway-client-behavior", root.GetProperty("fixtureSet").GetString());
|
||||||
|
Assert.Equal(GatewayContractInfo.GatewayProtocolVersion, root.GetProperty("gatewayProtocolVersion").GetUInt32());
|
||||||
|
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, root.GetProperty("workerProtocolVersion").GetUInt32());
|
||||||
|
|
||||||
|
HashSet<string> fixtureIds = new(StringComparer.Ordinal);
|
||||||
|
foreach (JsonElement fixture in root.GetProperty("fixtures").EnumerateArray())
|
||||||
|
{
|
||||||
|
string id = fixture.GetProperty("id").GetString()!;
|
||||||
|
string path = fixture.GetProperty("path").GetString()!;
|
||||||
|
string category = fixture.GetProperty("category").GetString()!;
|
||||||
|
string messageType = fixture.GetProperty("messageType").GetString()!;
|
||||||
|
|
||||||
|
Assert.True(fixtureIds.Add(id), $"Duplicate behavior fixture id '{id}'.");
|
||||||
|
Assert.Contains(category, KnownCategories);
|
||||||
|
Assert.Contains(messageType, KnownMessageTypes);
|
||||||
|
Assert.True(
|
||||||
|
File.Exists(Path.Combine(GetBehaviorFixtureRoot().FullName, path)),
|
||||||
|
$"Expected behavior fixture '{path}' to exist.");
|
||||||
|
Assert.False(Path.IsPathRooted(path), $"Fixture path '{path}' must be relative.");
|
||||||
|
Assert.NotEmpty(fixture.GetProperty("expectation").GetString()!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProtoInputManifest_ReferencesBehaviorFixtureRoot()
|
||||||
|
{
|
||||||
|
DirectoryInfo repositoryRoot = FindRepositoryRoot();
|
||||||
|
string manifestPath = Path.Combine(repositoryRoot.FullName, "clients", "proto", "proto-inputs.json");
|
||||||
|
|
||||||
|
using JsonDocument manifest = JsonDocument.Parse(File.ReadAllText(manifestPath));
|
||||||
|
string fixtureRoot = manifest.RootElement.GetProperty("behaviorFixtureRoot").GetString()!;
|
||||||
|
|
||||||
|
Assert.Equal("clients/proto/fixtures/behavior", fixtureRoot);
|
||||||
|
Assert.True(Directory.Exists(Path.Combine(repositoryRoot.FullName, fixtureRoot)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CommandReplyFixtures_ParseWithCurrentContractAndPreserveMxAccessDetails()
|
||||||
|
{
|
||||||
|
IReadOnlyList<JsonElement> fixtures = LoadManifestFixtures("command_replies");
|
||||||
|
Assert.NotEmpty(fixtures);
|
||||||
|
|
||||||
|
foreach (JsonElement fixture in fixtures)
|
||||||
|
{
|
||||||
|
MxCommandReply reply = ParseFixture<MxCommandReply>(
|
||||||
|
fixture,
|
||||||
|
MxCommandReply.Parser);
|
||||||
|
|
||||||
|
Assert.NotEqual(MxCommandKind.Unspecified, reply.Kind);
|
||||||
|
Assert.NotEqual(ProtocolStatusCode.Unspecified, reply.ProtocolStatus.Code);
|
||||||
|
Assert.True(reply.HasHresult, $"Fixture '{GetFixtureId(fixture)}' must carry an HRESULT.");
|
||||||
|
Assert.NotEmpty(reply.Statuses);
|
||||||
|
Assert.NotEqual(MxDataType.Unspecified, reply.ReturnValue.DataType);
|
||||||
|
Assert.True(
|
||||||
|
reply.ReturnValue.KindCase != MxValue.KindOneofCase.None || reply.ReturnValue.IsNull,
|
||||||
|
$"Fixture '{GetFixtureId(fixture)}' must carry a typed value, raw value, or explicit null.");
|
||||||
|
}
|
||||||
|
|
||||||
|
MxCommandReply failedWrite = ParseFixture<MxCommandReply>(
|
||||||
|
Assert.Single(fixtures, fixture => GetFixtureId(fixture) == "command-reply.write.mxaccess-failure"),
|
||||||
|
MxCommandReply.Parser);
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.MxaccessFailure, failedWrite.ProtocolStatus.Code);
|
||||||
|
Assert.Equal(-2147220992, failedWrite.Hresult);
|
||||||
|
Assert.True(failedWrite.Statuses.Count > 1);
|
||||||
|
Assert.All(failedWrite.Statuses, status => Assert.Equal(0, status.Success));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EventStreamFixtures_ParseWithMonotonicSequencesAndExpectedFamilies()
|
||||||
|
{
|
||||||
|
IReadOnlyList<JsonElement> fixtures = LoadManifestFixtures("event_streams");
|
||||||
|
Assert.NotEmpty(fixtures);
|
||||||
|
|
||||||
|
foreach (JsonElement fixture in fixtures)
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(GetFixturePath(fixture)));
|
||||||
|
ulong previousSequence = 0;
|
||||||
|
List<MxEventFamily> families = [];
|
||||||
|
|
||||||
|
foreach (JsonElement eventElement in document.RootElement.GetProperty("events").EnumerateArray())
|
||||||
|
{
|
||||||
|
MxEvent gatewayEvent = ProtobufJsonParser.Parse<MxEvent>(eventElement.GetRawText());
|
||||||
|
|
||||||
|
Assert.True(gatewayEvent.WorkerSequence > previousSequence);
|
||||||
|
Assert.Equal(document.RootElement.GetProperty("sessionId").GetString(), gatewayEvent.SessionId);
|
||||||
|
Assert.NotEmpty(gatewayEvent.Statuses);
|
||||||
|
AssertEventBodyMatchesFamily(gatewayEvent);
|
||||||
|
|
||||||
|
previousSequence = gatewayEvent.WorkerSequence;
|
||||||
|
families.Add(gatewayEvent.Family);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Contains(MxEventFamily.OnDataChange, families);
|
||||||
|
Assert.Contains(MxEventFamily.OnWriteComplete, families);
|
||||||
|
Assert.Contains(MxEventFamily.OperationComplete, families);
|
||||||
|
Assert.Contains(MxEventFamily.OnBufferedDataChange, families);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValueConversionFixtures_ParseTypedValuesAndRawFallbacks()
|
||||||
|
{
|
||||||
|
JsonElement cases = LoadCaseSet("value_conversion", "cases");
|
||||||
|
bool sawRawFallback = false;
|
||||||
|
bool sawRawArrayFallback = false;
|
||||||
|
bool sawTypedArray = false;
|
||||||
|
|
||||||
|
foreach (JsonElement valueCase in cases.EnumerateArray())
|
||||||
|
{
|
||||||
|
MxValue value = ProtobufJsonParser.Parse<MxValue>(
|
||||||
|
valueCase.GetProperty("value").GetRawText());
|
||||||
|
string expectedKind = valueCase.GetProperty("expectedKind").GetString()!;
|
||||||
|
|
||||||
|
Assert.NotEqual(MxDataType.Unspecified, value.DataType);
|
||||||
|
AssertJsonKindMatchesValueKind(expectedKind, value);
|
||||||
|
|
||||||
|
sawRawFallback |= value.KindCase == MxValue.KindOneofCase.RawValue
|
||||||
|
&& !string.IsNullOrWhiteSpace(value.RawDiagnostic)
|
||||||
|
&& value.RawDataType != 0;
|
||||||
|
sawRawArrayFallback |= value.KindCase == MxValue.KindOneofCase.ArrayValue
|
||||||
|
&& value.ArrayValue.ValuesCase == MxArray.ValuesOneofCase.RawValues
|
||||||
|
&& !string.IsNullOrWhiteSpace(value.ArrayValue.RawDiagnostic)
|
||||||
|
&& value.ArrayValue.RawElementDataType != 0;
|
||||||
|
sawTypedArray |= value.KindCase == MxValue.KindOneofCase.ArrayValue
|
||||||
|
&& value.ArrayValue.ValuesCase != MxArray.ValuesOneofCase.RawValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(sawRawFallback, "Expected at least one raw scalar fallback case.");
|
||||||
|
Assert.True(sawRawArrayFallback, "Expected at least one raw array fallback case.");
|
||||||
|
Assert.True(sawTypedArray, "Expected at least one typed array case.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StatusConversionFixtures_ParseStatusArraysAndRawFields()
|
||||||
|
{
|
||||||
|
JsonElement cases = LoadCaseSet("status_conversion", "cases");
|
||||||
|
bool sawRawUnknown = false;
|
||||||
|
|
||||||
|
foreach (JsonElement statusCase in cases.EnumerateArray())
|
||||||
|
{
|
||||||
|
MxStatusProxy status = ProtobufJsonParser.Parse<MxStatusProxy>(
|
||||||
|
statusCase.GetProperty("status").GetRawText());
|
||||||
|
|
||||||
|
Assert.NotEqual(MxStatusCategory.Unspecified, status.Category);
|
||||||
|
Assert.NotEqual(MxStatusSource.Unspecified, status.DetectedBy);
|
||||||
|
Assert.NotEmpty(status.DiagnosticText);
|
||||||
|
|
||||||
|
sawRawUnknown |= status.Category == MxStatusCategory.Unknown
|
||||||
|
&& status.RawCategory != 0
|
||||||
|
&& status.RawDetectedBy != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(sawRawUnknown, "Expected a status case with unknown raw native fields.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AuthErrorFixtures_MapAuthenticationAuthorizationAndRedactCredentials()
|
||||||
|
{
|
||||||
|
JsonElement cases = LoadCaseSet("auth_errors", "cases");
|
||||||
|
HashSet<string> statusCodes = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (JsonElement authCase in cases.EnumerateArray())
|
||||||
|
{
|
||||||
|
string grpcStatusCode = authCase.GetProperty("grpcStatusCode").GetString()!;
|
||||||
|
string category = authCase.GetProperty("clientErrorCategory").GetString()!;
|
||||||
|
string redactedOutput = authCase.GetProperty("expectedRedactedOutput").GetString()!;
|
||||||
|
string serialized = authCase.GetRawText();
|
||||||
|
|
||||||
|
Assert.Contains(grpcStatusCode, AuthGrpcStatusCodes);
|
||||||
|
Assert.Contains(category, AuthClientErrorCategories);
|
||||||
|
string authorization = authCase.GetProperty("inputMetadata").GetProperty("authorization").GetString()!;
|
||||||
|
if (!string.IsNullOrEmpty(authorization))
|
||||||
|
{
|
||||||
|
Assert.Contains("<redacted>", serialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.DoesNotContain("mxgw_", serialized, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("secret", redactedOutput, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
statusCodes.Add(grpcStatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Contains("UNAUTHENTICATED", statusCodes);
|
||||||
|
Assert.Contains("PERMISSION_DENIED", statusCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TimeoutCancelFixtures_DocumentClientWaitAndWorkerCommandBehavior()
|
||||||
|
{
|
||||||
|
JsonElement cases = LoadCaseSet("timeout_cancel", "cases");
|
||||||
|
HashSet<string> statusCodes = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (JsonElement timeoutCase in cases.EnumerateArray())
|
||||||
|
{
|
||||||
|
string grpcStatusCode = timeoutCase.GetProperty("grpcStatusCode").GetString()!;
|
||||||
|
|
||||||
|
Assert.Contains(grpcStatusCode, TimeoutGrpcStatusCodes);
|
||||||
|
Assert.NotEmpty(timeoutCase.GetProperty("clientDeadline").GetString()!);
|
||||||
|
Assert.NotEmpty(timeoutCase.GetProperty("gatewayWaitBehavior").GetString()!);
|
||||||
|
Assert.NotEmpty(timeoutCase.GetProperty("workerCommandBehavior").GetString()!);
|
||||||
|
Assert.NotEmpty(timeoutCase.GetProperty("expectedClientAction").GetString()!);
|
||||||
|
|
||||||
|
statusCodes.Add(grpcStatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Contains("DEADLINE_EXCEEDED", statusCodes);
|
||||||
|
Assert.Contains("CANCELLED", statusCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly string[] KnownCategories =
|
||||||
|
[
|
||||||
|
"command_replies",
|
||||||
|
"event_streams",
|
||||||
|
"value_conversion",
|
||||||
|
"status_conversion",
|
||||||
|
"auth_errors",
|
||||||
|
"timeout_cancel",
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] KnownMessageTypes =
|
||||||
|
[
|
||||||
|
"mxaccess_gateway.v1.MxCommandReply",
|
||||||
|
"mxaccess_gateway.v1.MxEvent",
|
||||||
|
"mxaccess_gateway.v1.MxValue",
|
||||||
|
"mxaccess_gateway.v1.MxStatusProxy",
|
||||||
|
"client_behavior.v1.AuthErrorCase",
|
||||||
|
"client_behavior.v1.TimeoutCancelCase",
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] AuthGrpcStatusCodes =
|
||||||
|
[
|
||||||
|
"UNAUTHENTICATED",
|
||||||
|
"PERMISSION_DENIED",
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] AuthClientErrorCategories =
|
||||||
|
[
|
||||||
|
"AuthenticationError",
|
||||||
|
"AuthorizationError",
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] TimeoutGrpcStatusCodes =
|
||||||
|
[
|
||||||
|
"DEADLINE_EXCEEDED",
|
||||||
|
"CANCELLED",
|
||||||
|
];
|
||||||
|
|
||||||
|
private static T ParseFixture<T>(
|
||||||
|
JsonElement fixture,
|
||||||
|
MessageParser<T> parser)
|
||||||
|
where T : IMessage<T>
|
||||||
|
{
|
||||||
|
return parser.ParseJson(File.ReadAllText(GetFixturePath(fixture)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement LoadCaseSet(
|
||||||
|
string category,
|
||||||
|
string propertyName)
|
||||||
|
{
|
||||||
|
JsonElement fixture = Assert.Single(LoadManifestFixtures(category));
|
||||||
|
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(GetFixturePath(fixture)));
|
||||||
|
|
||||||
|
return document.RootElement.GetProperty(propertyName).Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<JsonElement> LoadManifestFixtures(string category)
|
||||||
|
{
|
||||||
|
using JsonDocument manifest = LoadBehaviorManifest();
|
||||||
|
|
||||||
|
return manifest.RootElement
|
||||||
|
.GetProperty("fixtures")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Where(fixture => fixture.GetProperty("category").GetString() == category)
|
||||||
|
.Select(fixture => fixture.Clone())
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonDocument LoadBehaviorManifest()
|
||||||
|
{
|
||||||
|
return JsonDocument.Parse(File.ReadAllText(Path.Combine(GetBehaviorFixtureRoot().FullName, "manifest.json")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetFixturePath(JsonElement fixture)
|
||||||
|
{
|
||||||
|
return Path.Combine(GetBehaviorFixtureRoot().FullName, fixture.GetProperty("path").GetString()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetFixtureId(JsonElement fixture)
|
||||||
|
{
|
||||||
|
return fixture.GetProperty("id").GetString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DirectoryInfo GetBehaviorFixtureRoot()
|
||||||
|
{
|
||||||
|
DirectoryInfo repositoryRoot = FindRepositoryRoot();
|
||||||
|
|
||||||
|
return new DirectoryInfo(Path.Combine(repositoryRoot.FullName, "clients", "proto", "fixtures", "behavior"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DirectoryInfo FindRepositoryRoot()
|
||||||
|
{
|
||||||
|
DirectoryInfo? current = new(AppContext.BaseDirectory);
|
||||||
|
|
||||||
|
while (current is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(current.FullName, "AGENTS.md"))
|
||||||
|
&& Directory.Exists(Path.Combine(current.FullName, "src"))
|
||||||
|
&& Directory.Exists(Path.Combine(current.FullName, "clients")))
|
||||||
|
{
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new DirectoryNotFoundException("Could not locate the repository root from the test output directory.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertEventBodyMatchesFamily(MxEvent gatewayEvent)
|
||||||
|
{
|
||||||
|
switch (gatewayEvent.Family)
|
||||||
|
{
|
||||||
|
case MxEventFamily.OnDataChange:
|
||||||
|
Assert.Equal(MxEvent.BodyOneofCase.OnDataChange, gatewayEvent.BodyCase);
|
||||||
|
break;
|
||||||
|
case MxEventFamily.OnWriteComplete:
|
||||||
|
Assert.Equal(MxEvent.BodyOneofCase.OnWriteComplete, gatewayEvent.BodyCase);
|
||||||
|
break;
|
||||||
|
case MxEventFamily.OperationComplete:
|
||||||
|
Assert.Equal(MxEvent.BodyOneofCase.OperationComplete, gatewayEvent.BodyCase);
|
||||||
|
break;
|
||||||
|
case MxEventFamily.OnBufferedDataChange:
|
||||||
|
Assert.Equal(MxEvent.BodyOneofCase.OnBufferedDataChange, gatewayEvent.BodyCase);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException($"Unexpected event family '{gatewayEvent.Family}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertJsonKindMatchesValueKind(
|
||||||
|
string expectedKind,
|
||||||
|
MxValue value)
|
||||||
|
{
|
||||||
|
MxValue.KindOneofCase expected = expectedKind switch
|
||||||
|
{
|
||||||
|
"boolValue" => MxValue.KindOneofCase.BoolValue,
|
||||||
|
"int32Value" => MxValue.KindOneofCase.Int32Value,
|
||||||
|
"int64Value" => MxValue.KindOneofCase.Int64Value,
|
||||||
|
"floatValue" => MxValue.KindOneofCase.FloatValue,
|
||||||
|
"doubleValue" => MxValue.KindOneofCase.DoubleValue,
|
||||||
|
"stringValue" => MxValue.KindOneofCase.StringValue,
|
||||||
|
"timestampValue" => MxValue.KindOneofCase.TimestampValue,
|
||||||
|
"arrayValue" => MxValue.KindOneofCase.ArrayValue,
|
||||||
|
"rawValue" => MxValue.KindOneofCase.RawValue,
|
||||||
|
_ => throw new InvalidOperationException($"Unexpected expected value kind '{expectedKind}'."),
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(expected, value.KindCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,26 @@ public sealed class SessionManagerTests
|
|||||||
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
|
Assert.Equal(0, metrics.GetSnapshot().OpenSessions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CloseSessionAsync_WhenWorkerShutdownFails_KillsWorker()
|
||||||
|
{
|
||||||
|
FakeWorkerClient workerClient = new()
|
||||||
|
{
|
||||||
|
ShutdownException = new WorkerClientException(
|
||||||
|
WorkerClientErrorCode.ShutdownTimeout,
|
||||||
|
"Worker shutdown timed out."),
|
||||||
|
};
|
||||||
|
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient));
|
||||||
|
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
|
||||||
|
|
||||||
|
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
|
||||||
|
async () => await manager.CloseSessionAsync(session.SessionId, CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Equal(SessionManagerErrorCode.CloseFailed, exception.ErrorCode);
|
||||||
|
Assert.Equal(1, workerClient.ShutdownCount);
|
||||||
|
Assert.Equal(1, workerClient.KillCount);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenSessionAsync_WhenWorkerCreationFails_RemovesSessionFromRegistry()
|
public async Task OpenSessionAsync_WhenWorkerCreationFails_RemovesSessionFromRegistry()
|
||||||
{
|
{
|
||||||
@@ -266,6 +286,8 @@ public sealed class SessionManagerTests
|
|||||||
|
|
||||||
public int KillCount { get; private set; }
|
public int KillCount { get; private set; }
|
||||||
|
|
||||||
|
public Exception? ShutdownException { get; init; }
|
||||||
|
|
||||||
public Task StartAsync(CancellationToken cancellationToken)
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@@ -302,6 +324,11 @@ public sealed class SessionManagerTests
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ShutdownCount++;
|
ShutdownCount++;
|
||||||
|
if (ShutdownException is not null)
|
||||||
|
{
|
||||||
|
throw ShutdownException;
|
||||||
|
}
|
||||||
|
|
||||||
State = WorkerClientState.Closed;
|
State = WorkerClientState.Closed;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,25 @@ public sealed class FakeWorkerHarnessTests
|
|||||||
Assert.Equal(WorkerClientState.Faulted, client.State);
|
Assert.Equal(WorkerClientState.Faulted, client.State);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendHeartbeatAsync_UpdatesClientHeartbeatState()
|
||||||
|
{
|
||||||
|
await using FakeWorkerHarness fakeWorker = await FakeWorkerHarness.CreateConnectedPairAsync();
|
||||||
|
await using WorkerClient client = fakeWorker.CreateClient();
|
||||||
|
await StartClientAsync(fakeWorker, client);
|
||||||
|
DateTimeOffset previousHeartbeat = client.LastHeartbeatAt;
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(20));
|
||||||
|
await fakeWorker.SendHeartbeatAsync(
|
||||||
|
configureHeartbeat: heartbeat => heartbeat.WorkerProcessId = 2468);
|
||||||
|
|
||||||
|
await WaitUntilAsync(
|
||||||
|
() => client.ProcessId == 2468 && client.LastHeartbeatAt > previousHeartbeat,
|
||||||
|
TestTimeout);
|
||||||
|
|
||||||
|
Assert.Equal(WorkerClientState.Ready, client.State);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvokeAsync_WithHungWorker_TimesOutPendingCommand()
|
public async Task InvokeAsync_WithHungWorker_TimesOutPendingCommand()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -284,6 +284,26 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
|
|||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SendHeartbeatAsync(
|
||||||
|
WorkerState state = WorkerState.Ready,
|
||||||
|
CancellationToken cancellationToken = default,
|
||||||
|
Action<WorkerHeartbeat>? configureHeartbeat = null)
|
||||||
|
{
|
||||||
|
WorkerHeartbeat heartbeat = new()
|
||||||
|
{
|
||||||
|
WorkerProcessId = DefaultWorkerProcessId,
|
||||||
|
State = state,
|
||||||
|
LastStaActivityTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||||
|
};
|
||||||
|
configureHeartbeat?.Invoke(heartbeat);
|
||||||
|
|
||||||
|
await _writer.WriteAsync(
|
||||||
|
CreateEnvelope(
|
||||||
|
correlationId: string.Empty,
|
||||||
|
envelope => envelope.WorkerHeartbeat = heartbeat),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SendShutdownAckAsync(
|
public async Task SendShutdownAckAsync(
|
||||||
ProtocolStatusCode statusCode = ProtocolStatusCode.Ok,
|
ProtocolStatusCode statusCode = ProtocolStatusCode.Ok,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.IO.Pipes;
|
using System.IO.Pipes;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using MxGateway.Contracts;
|
using MxGateway.Contracts;
|
||||||
using MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using MxGateway.Server.Workers;
|
using MxGateway.Server.Workers;
|
||||||
@@ -151,6 +152,24 @@ public sealed class WorkerClientTests
|
|||||||
Assert.Equal(WorkerClientState.Faulted, client.State);
|
Assert.Equal(WorkerClientState.Faulted, client.State);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReadLoop_WhenHeartbeatArrives_UpdatesLastHeartbeatAndWorkerProcess()
|
||||||
|
{
|
||||||
|
await using PipePair pipePair = await PipePair.CreateAsync();
|
||||||
|
await using WorkerClient client = CreateClient(pipePair);
|
||||||
|
await CompleteHandshakeAsync(client, pipePair);
|
||||||
|
DateTimeOffset previousHeartbeat = client.LastHeartbeatAt;
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(20));
|
||||||
|
await pipePair.WorkerWriter.WriteAsync(CreateHeartbeatEnvelope(workerProcessId: 9876));
|
||||||
|
|
||||||
|
await WaitUntilAsync(
|
||||||
|
() => client.ProcessId == 9876 && client.LastHeartbeatAt > previousHeartbeat,
|
||||||
|
TestTimeout);
|
||||||
|
|
||||||
|
Assert.Equal(WorkerClientState.Ready, client.State);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task HeartbeatMonitor_WhenHeartbeatExpires_FaultsClient()
|
public async Task HeartbeatMonitor_WhenHeartbeatExpires_FaultsClient()
|
||||||
{
|
{
|
||||||
@@ -276,6 +295,21 @@ public sealed class WorkerClientTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static WorkerEnvelope CreateHeartbeatEnvelope(int workerProcessId)
|
||||||
|
{
|
||||||
|
return CreateWorkerEnvelope(
|
||||||
|
correlationId: string.Empty,
|
||||||
|
sequence: 20,
|
||||||
|
envelope => envelope.WorkerHeartbeat = new WorkerHeartbeat
|
||||||
|
{
|
||||||
|
WorkerProcessId = workerProcessId,
|
||||||
|
State = WorkerState.Ready,
|
||||||
|
LastStaActivityTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||||
|
PendingCommandCount = 0,
|
||||||
|
OutboundEventQueueDepth = 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static WorkerEnvelope CreateWorkerEnvelope(
|
private static WorkerEnvelope CreateWorkerEnvelope(
|
||||||
string correlationId,
|
string correlationId,
|
||||||
ulong sequence,
|
ulong sequence,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public sealed class WorkerApplicationTests
|
|||||||
Assert.Equal("mxaccess-gateway-123-session-1", entry.Fields["pipe_name"]);
|
Assert.Equal("mxaccess-gateway-123-session-1", entry.Fields["pipe_name"]);
|
||||||
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, entry.Fields["protocol_version"]);
|
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, entry.Fields["protocol_version"]);
|
||||||
Assert.Equal("[redacted]", entry.Fields["nonce"]);
|
Assert.Equal("[redacted]", entry.Fields["nonce"]);
|
||||||
Assert.Equal("WorkerPipeHandshakeSucceeded", logger.Entries[1].EventName);
|
Assert.Equal("WorkerPipeSessionCompleted", logger.Entries[1].EventName);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
using System.IO.Pipes;
|
using System.IO.Pipes;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using MxGateway.Contracts;
|
using MxGateway.Contracts;
|
||||||
using MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using MxGateway.Worker.Bootstrap;
|
using MxGateway.Worker.Bootstrap;
|
||||||
using MxGateway.Worker.Ipc;
|
using MxGateway.Worker.Ipc;
|
||||||
|
using MxGateway.Worker.MxAccess;
|
||||||
|
using MxGateway.Worker.Sta;
|
||||||
|
|
||||||
namespace MxGateway.Worker.Tests.Ipc;
|
namespace MxGateway.Worker.Tests.Ipc;
|
||||||
|
|
||||||
@@ -28,7 +33,9 @@ public sealed class WorkerPipeClientTests
|
|||||||
PipeTransmissionMode.Byte,
|
PipeTransmissionMode.Byte,
|
||||||
PipeOptions.Asynchronous);
|
PipeOptions.Asynchronous);
|
||||||
|
|
||||||
WorkerPipeClient client = new(connectTimeoutMilliseconds: 5000);
|
WorkerPipeClient client = new(
|
||||||
|
connectTimeoutMilliseconds: 5000,
|
||||||
|
(stream, options) => CreateSession(stream, options));
|
||||||
Task clientTask = client.RunAsync(workerOptions);
|
Task clientTask = client.RunAsync(workerOptions);
|
||||||
|
|
||||||
await Task.Factory.FromAsync(server.BeginWaitForConnection, server.EndWaitForConnection, null);
|
await Task.Factory.FromAsync(server.BeginWaitForConnection, server.EndWaitForConnection, null);
|
||||||
@@ -56,6 +63,94 @@ public sealed class WorkerPipeClientTests
|
|||||||
WorkerEnvelope ready = await reader.ReadAsync();
|
WorkerEnvelope ready = await reader.ReadAsync();
|
||||||
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerReady, ready.BodyCase);
|
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerReady, ready.BodyCase);
|
||||||
|
|
||||||
|
await writer.WriteAsync(new WorkerEnvelope
|
||||||
|
{
|
||||||
|
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||||
|
SessionId = "session-1",
|
||||||
|
Sequence = 2,
|
||||||
|
WorkerShutdown = new WorkerShutdown
|
||||||
|
{
|
||||||
|
GracePeriod = Duration.FromTimeSpan(TimeSpan.FromSeconds(1)),
|
||||||
|
Reason = "test-complete",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
WorkerEnvelope shutdownAck = await reader.ReadAsync();
|
||||||
|
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerShutdownAck, shutdownAck.BodyCase);
|
||||||
await clientTask;
|
await clientTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static WorkerPipeSession CreateSession(
|
||||||
|
Stream stream,
|
||||||
|
WorkerFrameProtocolOptions options)
|
||||||
|
{
|
||||||
|
return new WorkerPipeSession(
|
||||||
|
new WorkerFrameReader(stream, options),
|
||||||
|
new WorkerFrameWriter(stream, options),
|
||||||
|
options,
|
||||||
|
() => 1234,
|
||||||
|
new WorkerPipeSessionOptions
|
||||||
|
{
|
||||||
|
HeartbeatInterval = TimeSpan.FromSeconds(30),
|
||||||
|
HeartbeatGrace = TimeSpan.FromSeconds(30),
|
||||||
|
},
|
||||||
|
() => new FakeRuntimeSession());
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeRuntimeSession : IWorkerRuntimeSession
|
||||||
|
{
|
||||||
|
public Task<WorkerReady> StartAsync(
|
||||||
|
string sessionId,
|
||||||
|
int workerProcessId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new WorkerReady
|
||||||
|
{
|
||||||
|
WorkerProcessId = workerProcessId,
|
||||||
|
MxaccessProgid = MxGateway.Worker.MxAccess.MxAccessInteropInfo.ProgId,
|
||||||
|
MxaccessClsid = MxGateway.Worker.MxAccess.MxAccessInteropInfo.Clsid,
|
||||||
|
ReadyTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> DispatchAsync(StaCommand command)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = command.SessionId,
|
||||||
|
CorrelationId = command.CorrelationId,
|
||||||
|
Kind = command.Kind,
|
||||||
|
ProtocolStatus = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.Ok,
|
||||||
|
Message = "OK",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat()
|
||||||
|
{
|
||||||
|
return new WorkerRuntimeHeartbeatSnapshot(
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
pendingCommandCount: 0,
|
||||||
|
outboundEventQueueDepth: 0,
|
||||||
|
lastEventSequence: 0,
|
||||||
|
currentCommandCorrelationId: string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RequestShutdown()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxAccessShutdownResult> ShutdownGracefullyAsync(
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.IO.Pipes;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using MxGateway.Contracts;
|
using MxGateway.Contracts;
|
||||||
using MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
using MxGateway.Worker.Ipc;
|
using MxGateway.Worker.Ipc;
|
||||||
|
using MxGateway.Worker.MxAccess;
|
||||||
|
using MxGateway.Worker.Sta;
|
||||||
|
|
||||||
namespace MxGateway.Worker.Tests.Ipc;
|
namespace MxGateway.Worker.Tests.Ipc;
|
||||||
|
|
||||||
@@ -147,6 +152,127 @@ public sealed class WorkerPipeSessionTests
|
|||||||
Assert.Equal(ProtocolStatusCode.WorkerUnavailable, written[1].WorkerFault.ProtocolStatus.Code);
|
Assert.Equal(ProtocolStatusCode.WorkerUnavailable, written[1].WorkerFault.ProtocolStatus.Code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_SendsHeartbeatPayloadFromRuntimeSnapshot()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(5));
|
||||||
|
using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token);
|
||||||
|
FakeRuntimeSession runtime = new();
|
||||||
|
runtime.SetSnapshot(new WorkerRuntimeHeartbeatSnapshot(
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
pendingCommandCount: 2,
|
||||||
|
outboundEventQueueDepth: 3,
|
||||||
|
lastEventSequence: 42,
|
||||||
|
currentCommandCorrelationId: "current-command"));
|
||||||
|
WorkerPipeSession session = CreatePipeSession(
|
||||||
|
pipePair.WorkerStream,
|
||||||
|
runtime,
|
||||||
|
new WorkerPipeSessionOptions
|
||||||
|
{
|
||||||
|
HeartbeatInterval = TimeSpan.FromMilliseconds(20),
|
||||||
|
HeartbeatGrace = TimeSpan.FromSeconds(5),
|
||||||
|
});
|
||||||
|
Task runTask = session.RunAsync(cancellation.Token);
|
||||||
|
|
||||||
|
await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token);
|
||||||
|
await ThrowIfCompletedAsync(runTask);
|
||||||
|
|
||||||
|
WorkerEnvelope heartbeat = await ReadUntilAsync(
|
||||||
|
pipePair.GatewayReader,
|
||||||
|
WorkerEnvelope.BodyOneofCase.WorkerHeartbeat,
|
||||||
|
cancellation.Token);
|
||||||
|
|
||||||
|
Assert.Equal(WorkerState.ExecutingCommand, heartbeat.WorkerHeartbeat.State);
|
||||||
|
Assert.Equal(1234, heartbeat.WorkerHeartbeat.WorkerProcessId);
|
||||||
|
Assert.Equal(2u, heartbeat.WorkerHeartbeat.PendingCommandCount);
|
||||||
|
Assert.Equal(3u, heartbeat.WorkerHeartbeat.OutboundEventQueueDepth);
|
||||||
|
Assert.Equal(42UL, heartbeat.WorkerHeartbeat.LastEventSequence);
|
||||||
|
Assert.Equal("current-command", heartbeat.WorkerHeartbeat.CurrentCommandCorrelationId);
|
||||||
|
|
||||||
|
await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_WhenCommandIsExecuting_HeartbeatReportsCurrentCorrelation()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(5));
|
||||||
|
using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token);
|
||||||
|
FakeRuntimeSession runtime = new()
|
||||||
|
{
|
||||||
|
BlockDispatch = true,
|
||||||
|
};
|
||||||
|
WorkerPipeSession session = CreatePipeSession(
|
||||||
|
pipePair.WorkerStream,
|
||||||
|
runtime,
|
||||||
|
new WorkerPipeSessionOptions
|
||||||
|
{
|
||||||
|
HeartbeatInterval = TimeSpan.FromMilliseconds(20),
|
||||||
|
HeartbeatGrace = TimeSpan.FromSeconds(5),
|
||||||
|
});
|
||||||
|
Task runTask = session.RunAsync(cancellation.Token);
|
||||||
|
await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token);
|
||||||
|
|
||||||
|
await pipePair.GatewayWriter.WriteAsync(
|
||||||
|
CreateCommandEnvelope("command-1"),
|
||||||
|
cancellation.Token);
|
||||||
|
|
||||||
|
Assert.True(runtime.DispatchStarted.Wait(TimeSpan.FromSeconds(2)));
|
||||||
|
WorkerEnvelope heartbeat = await ReadUntilAsync(
|
||||||
|
pipePair.GatewayReader,
|
||||||
|
WorkerEnvelope.BodyOneofCase.WorkerHeartbeat,
|
||||||
|
envelope => envelope.WorkerHeartbeat.CurrentCommandCorrelationId == "command-1",
|
||||||
|
cancellation.Token);
|
||||||
|
|
||||||
|
Assert.Equal("command-1", heartbeat.WorkerHeartbeat.CurrentCommandCorrelationId);
|
||||||
|
Assert.Equal(WorkerState.ExecutingCommand, heartbeat.WorkerHeartbeat.State);
|
||||||
|
|
||||||
|
runtime.ReleaseDispatch();
|
||||||
|
WorkerEnvelope reply = await ReadUntilAsync(
|
||||||
|
pipePair.GatewayReader,
|
||||||
|
WorkerEnvelope.BodyOneofCase.WorkerCommandReply,
|
||||||
|
cancellation.Token);
|
||||||
|
|
||||||
|
Assert.Equal("command-1", reply.CorrelationId);
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, reply.WorkerCommandReply.Reply.ProtocolStatus.Code);
|
||||||
|
|
||||||
|
await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunAsync_WhenStaActivityIsStale_WritesWatchdogFault()
|
||||||
|
{
|
||||||
|
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(5));
|
||||||
|
using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token);
|
||||||
|
FakeRuntimeSession runtime = new();
|
||||||
|
runtime.SetSnapshot(new WorkerRuntimeHeartbeatSnapshot(
|
||||||
|
DateTimeOffset.UtcNow - TimeSpan.FromSeconds(5),
|
||||||
|
pendingCommandCount: 0,
|
||||||
|
outboundEventQueueDepth: 0,
|
||||||
|
lastEventSequence: 0,
|
||||||
|
currentCommandCorrelationId: "stuck-command"));
|
||||||
|
WorkerPipeSession session = CreatePipeSession(
|
||||||
|
pipePair.WorkerStream,
|
||||||
|
runtime,
|
||||||
|
new WorkerPipeSessionOptions
|
||||||
|
{
|
||||||
|
HeartbeatInterval = TimeSpan.FromMilliseconds(20),
|
||||||
|
HeartbeatGrace = TimeSpan.FromMilliseconds(50),
|
||||||
|
});
|
||||||
|
Task runTask = session.RunAsync(cancellation.Token);
|
||||||
|
await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token);
|
||||||
|
|
||||||
|
WorkerEnvelope fault = await ReadUntilAsync(
|
||||||
|
pipePair.GatewayReader,
|
||||||
|
WorkerEnvelope.BodyOneofCase.WorkerFault,
|
||||||
|
cancellation.Token);
|
||||||
|
|
||||||
|
Assert.Equal(WorkerFaultCategory.StaHung, fault.WorkerFault.Category);
|
||||||
|
Assert.Equal("stuck-command", fault.WorkerFault.CommandMethod);
|
||||||
|
Assert.Contains("STA activity is stale", fault.WorkerFault.DiagnosticMessage);
|
||||||
|
|
||||||
|
await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token);
|
||||||
|
}
|
||||||
|
|
||||||
private static WorkerPipeSession CreateSession(
|
private static WorkerPipeSession CreateSession(
|
||||||
Stream inbound,
|
Stream inbound,
|
||||||
Stream outbound,
|
Stream outbound,
|
||||||
@@ -159,6 +285,21 @@ public sealed class WorkerPipeSessionTests
|
|||||||
() => 1234);
|
() => 1234);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static WorkerPipeSession CreatePipeSession(
|
||||||
|
Stream stream,
|
||||||
|
FakeRuntimeSession runtime,
|
||||||
|
WorkerPipeSessionOptions sessionOptions)
|
||||||
|
{
|
||||||
|
WorkerFrameProtocolOptions options = CreateOptions();
|
||||||
|
return new WorkerPipeSession(
|
||||||
|
new WorkerFrameReader(stream, options),
|
||||||
|
new WorkerFrameWriter(stream, options),
|
||||||
|
options,
|
||||||
|
() => 1234,
|
||||||
|
sessionOptions,
|
||||||
|
() => runtime);
|
||||||
|
}
|
||||||
|
|
||||||
private static WorkerFrameProtocolOptions CreateOptions()
|
private static WorkerFrameProtocolOptions CreateOptions()
|
||||||
{
|
{
|
||||||
return new WorkerFrameProtocolOptions(
|
return new WorkerFrameProtocolOptions(
|
||||||
@@ -185,6 +326,119 @@ public sealed class WorkerPipeSessionTests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static WorkerEnvelope CreateCommandEnvelope(string correlationId)
|
||||||
|
{
|
||||||
|
return new WorkerEnvelope
|
||||||
|
{
|
||||||
|
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||||
|
SessionId = SessionId,
|
||||||
|
Sequence = 2,
|
||||||
|
CorrelationId = correlationId,
|
||||||
|
WorkerCommand = new WorkerCommand
|
||||||
|
{
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Ping,
|
||||||
|
Ping = new PingCommand
|
||||||
|
{
|
||||||
|
Message = "ping",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
EnqueueTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkerEnvelope CreateShutdownEnvelope()
|
||||||
|
{
|
||||||
|
return new WorkerEnvelope
|
||||||
|
{
|
||||||
|
ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||||
|
SessionId = SessionId,
|
||||||
|
Sequence = 3,
|
||||||
|
WorkerShutdown = new WorkerShutdown
|
||||||
|
{
|
||||||
|
GracePeriod = Duration.FromTimeSpan(TimeSpan.FromSeconds(1)),
|
||||||
|
Reason = "test-complete",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task CompleteGatewayHandshakeAsync(
|
||||||
|
PipePair pipePair,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await pipePair.GatewayWriter
|
||||||
|
.WriteAsync(CreateGatewayHelloEnvelope(), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
WorkerEnvelope hello = await pipePair.GatewayReader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
WorkerEnvelope ready = await pipePair.GatewayReader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerHello, hello.BodyCase);
|
||||||
|
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerReady, ready.BodyCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SendShutdownAndWaitAsync(
|
||||||
|
PipePair pipePair,
|
||||||
|
Task runTask,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await pipePair.GatewayWriter
|
||||||
|
.WriteAsync(CreateShutdownEnvelope(), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
WorkerEnvelope shutdownAck = await ReadUntilAsync(
|
||||||
|
pipePair.GatewayReader,
|
||||||
|
WorkerEnvelope.BodyOneofCase.WorkerShutdownAck,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, shutdownAck.WorkerShutdownAck.Status.Code);
|
||||||
|
Task completedTask = await Task
|
||||||
|
.WhenAny(runTask, Task.Delay(TimeSpan.FromSeconds(2), cancellationToken))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
Assert.Same(runTask, completedTask);
|
||||||
|
await runTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ThrowIfCompletedAsync(Task task)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(100));
|
||||||
|
if (task.IsCompleted)
|
||||||
|
{
|
||||||
|
await task;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<WorkerEnvelope> ReadUntilAsync(
|
||||||
|
WorkerFrameReader reader,
|
||||||
|
WorkerEnvelope.BodyOneofCase expectedBody,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return ReadUntilAsync(
|
||||||
|
reader,
|
||||||
|
expectedBody,
|
||||||
|
_ => true,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<WorkerEnvelope> ReadUntilAsync(
|
||||||
|
WorkerFrameReader reader,
|
||||||
|
WorkerEnvelope.BodyOneofCase expectedBody,
|
||||||
|
Func<WorkerEnvelope, bool> predicate,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
WorkerEnvelope envelope = await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (envelope.BodyCase == expectedBody && predicate(envelope))
|
||||||
|
{
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static WorkerEnvelope[] ReadWrittenFrames(
|
private static WorkerEnvelope[] ReadWrittenFrames(
|
||||||
MemoryStream stream,
|
MemoryStream stream,
|
||||||
WorkerFrameProtocolOptions options)
|
WorkerFrameProtocolOptions options)
|
||||||
@@ -219,4 +473,166 @@ public sealed class WorkerPipeSessionTests
|
|||||||
buffer[2] = (byte)(value >> 16);
|
buffer[2] = (byte)(value >> 16);
|
||||||
buffer[3] = (byte)(value >> 24);
|
buffer[3] = (byte)(value >> 24);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class FakeRuntimeSession : IWorkerRuntimeSession
|
||||||
|
{
|
||||||
|
private readonly ManualResetEventSlim releaseDispatch = new(false);
|
||||||
|
private readonly object gate = new();
|
||||||
|
private WorkerRuntimeHeartbeatSnapshot snapshot = new(
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
pendingCommandCount: 0,
|
||||||
|
outboundEventQueueDepth: 0,
|
||||||
|
lastEventSequence: 0,
|
||||||
|
currentCommandCorrelationId: string.Empty);
|
||||||
|
|
||||||
|
public ManualResetEventSlim DispatchStarted { get; } = new(false);
|
||||||
|
|
||||||
|
public bool BlockDispatch { get; set; }
|
||||||
|
|
||||||
|
public Task<WorkerReady> StartAsync(
|
||||||
|
string sessionId,
|
||||||
|
int workerProcessId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new WorkerReady
|
||||||
|
{
|
||||||
|
WorkerProcessId = workerProcessId,
|
||||||
|
MxaccessProgid = MxGateway.Worker.MxAccess.MxAccessInteropInfo.ProgId,
|
||||||
|
MxaccessClsid = MxGateway.Worker.MxAccess.MxAccessInteropInfo.Clsid,
|
||||||
|
ReadyTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxCommandReply> DispatchAsync(StaCommand command)
|
||||||
|
{
|
||||||
|
return Task.Run(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
SetSnapshot(new WorkerRuntimeHeartbeatSnapshot(
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
pendingCommandCount: 0,
|
||||||
|
outboundEventQueueDepth: 0,
|
||||||
|
lastEventSequence: 0,
|
||||||
|
command.CorrelationId));
|
||||||
|
DispatchStarted.Set();
|
||||||
|
|
||||||
|
if (BlockDispatch)
|
||||||
|
{
|
||||||
|
releaseDispatch.Wait(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
SetSnapshot(new WorkerRuntimeHeartbeatSnapshot(
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
pendingCommandCount: 0,
|
||||||
|
outboundEventQueueDepth: 0,
|
||||||
|
lastEventSequence: 0,
|
||||||
|
currentCommandCorrelationId: string.Empty));
|
||||||
|
|
||||||
|
return new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = command.SessionId,
|
||||||
|
CorrelationId = command.CorrelationId,
|
||||||
|
Kind = command.Kind,
|
||||||
|
ProtocolStatus = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.Ok,
|
||||||
|
Message = "OK",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat()
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RequestShutdown()
|
||||||
|
{
|
||||||
|
releaseDispatch.Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MxAccessShutdownResult> ShutdownGracefullyAsync(
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
releaseDispatch.Set();
|
||||||
|
return Task.FromResult(new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReleaseDispatch()
|
||||||
|
{
|
||||||
|
releaseDispatch.Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetSnapshot(WorkerRuntimeHeartbeatSnapshot value)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
snapshot = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
releaseDispatch.Set();
|
||||||
|
releaseDispatch.Dispose();
|
||||||
|
DispatchStarted.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PipePair : IDisposable
|
||||||
|
{
|
||||||
|
private readonly NamedPipeServerStream gatewayStream;
|
||||||
|
|
||||||
|
private PipePair(
|
||||||
|
NamedPipeServerStream gatewayStream,
|
||||||
|
NamedPipeClientStream workerStream)
|
||||||
|
{
|
||||||
|
this.gatewayStream = gatewayStream;
|
||||||
|
WorkerStream = workerStream;
|
||||||
|
WorkerFrameProtocolOptions options = CreateOptions();
|
||||||
|
GatewayReader = new WorkerFrameReader(gatewayStream, options);
|
||||||
|
GatewayWriter = new WorkerFrameWriter(gatewayStream, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Stream WorkerStream { get; }
|
||||||
|
|
||||||
|
public WorkerFrameReader GatewayReader { get; }
|
||||||
|
|
||||||
|
public WorkerFrameWriter GatewayWriter { get; }
|
||||||
|
|
||||||
|
public static async Task<PipePair> CreateAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string pipeName = $"mxaccessgw-worker-session-tests-{Guid.NewGuid():N}";
|
||||||
|
NamedPipeServerStream gatewayStream = new(
|
||||||
|
pipeName,
|
||||||
|
PipeDirection.InOut,
|
||||||
|
maxNumberOfServerInstances: 1,
|
||||||
|
PipeTransmissionMode.Byte,
|
||||||
|
PipeOptions.Asynchronous);
|
||||||
|
NamedPipeClientStream workerStream = new(
|
||||||
|
".",
|
||||||
|
pipeName,
|
||||||
|
PipeDirection.InOut,
|
||||||
|
PipeOptions.Asynchronous);
|
||||||
|
|
||||||
|
Task waitForConnectionTask = gatewayStream.WaitForConnectionAsync();
|
||||||
|
await Task
|
||||||
|
.Run(() => workerStream.Connect(5000), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await waitForConnectionTask.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new PipePair(gatewayStream, workerStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
WorkerStream.Dispose();
|
||||||
|
gatewayStream.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
@@ -414,6 +416,57 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
Assert.Equal(MxAccessAdviceKind.Plain, adviceHandle.AdviceKind);
|
Assert.Equal(MxAccessAdviceKind.Plain, adviceHandle.AdviceKind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ShutdownGracefullyAsync_CleansHandlesInAdviceItemServerOrder()
|
||||||
|
{
|
||||||
|
FakeMxAccessComObject fakeComObject = new(
|
||||||
|
registerHandle: 58,
|
||||||
|
addItemHandle: 510);
|
||||||
|
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-shutdown", "client-a"));
|
||||||
|
await session.DispatchAsync(CreateAddItemCommand("add-before-shutdown", 58, "Galaxy.Tag.Value"));
|
||||||
|
await session.DispatchAsync(CreateAdviseCommand("advise-before-shutdown", 58, 510));
|
||||||
|
await session.DispatchAsync(CreateAdviseSupervisoryCommand("supervisory-before-shutdown", 58, 510));
|
||||||
|
|
||||||
|
MxAccessShutdownResult result = await session.ShutdownGracefullyAsync(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { "UnAdvise:58:510", "RemoveItem:58:510", "Unregister:58" },
|
||||||
|
fakeComObject.OperationNames.Where(name => name.StartsWith("Un", StringComparison.Ordinal)
|
||||||
|
|| name.StartsWith("Remove", StringComparison.Ordinal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ShutdownGracefullyAsync_RecordsCleanupFailuresAndContinues()
|
||||||
|
{
|
||||||
|
const int hresult = unchecked((int)0x80070057);
|
||||||
|
COMException cleanupException = new("Invalid handle.", hresult);
|
||||||
|
FakeMxAccessComObject fakeComObject = new(
|
||||||
|
registerHandle: 59,
|
||||||
|
addItemHandle: 511,
|
||||||
|
unregisterException: cleanupException,
|
||||||
|
removeItemException: cleanupException,
|
||||||
|
unAdviseException: cleanupException);
|
||||||
|
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-shutdown-failure", "client-a"));
|
||||||
|
await session.DispatchAsync(CreateAddItemCommand("add-before-shutdown-failure", 59, "Galaxy.Tag.Value"));
|
||||||
|
await session.DispatchAsync(CreateAdviseCommand("advise-before-shutdown-failure", 59, 511));
|
||||||
|
|
||||||
|
MxAccessShutdownResult result = await session.ShutdownGracefullyAsync(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
Assert.False(result.Succeeded);
|
||||||
|
Assert.Equal(new[] { "UnAdvise", "RemoveItem", "Unregister" }, result.Failures.Select(failure => failure.Operation));
|
||||||
|
Assert.All(result.Failures, failure => Assert.Equal(hresult, failure.HResult));
|
||||||
|
Assert.Contains("Unregister:59", fakeComObject.OperationNames);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DispatchAsync_RegisterWithoutPayload_ReturnsInvalidRequest()
|
public async Task DispatchAsync_RegisterWithoutPayload_ReturnsInvalidRequest()
|
||||||
{
|
{
|
||||||
@@ -644,6 +697,7 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
private readonly Exception? adviseException;
|
private readonly Exception? adviseException;
|
||||||
private readonly Exception? unAdviseException;
|
private readonly Exception? unAdviseException;
|
||||||
private readonly Exception? adviseSupervisoryException;
|
private readonly Exception? adviseSupervisoryException;
|
||||||
|
private readonly List<string> operationNames = new();
|
||||||
|
|
||||||
public FakeMxAccessComObject(
|
public FakeMxAccessComObject(
|
||||||
int registerHandle,
|
int registerHandle,
|
||||||
@@ -715,8 +769,11 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
|
|
||||||
public int? AdviseSupervisoryThreadId { get; private set; }
|
public int? AdviseSupervisoryThreadId { get; private set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<string> OperationNames => operationNames.ToArray();
|
||||||
|
|
||||||
public int Register(string clientName)
|
public int Register(string clientName)
|
||||||
{
|
{
|
||||||
|
operationNames.Add($"Register:{clientName}");
|
||||||
RegisteredClientName = clientName;
|
RegisteredClientName = clientName;
|
||||||
RegisterThreadId = Environment.CurrentManagedThreadId;
|
RegisterThreadId = Environment.CurrentManagedThreadId;
|
||||||
|
|
||||||
@@ -725,6 +782,7 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
|
|
||||||
public void Unregister(int serverHandle)
|
public void Unregister(int serverHandle)
|
||||||
{
|
{
|
||||||
|
operationNames.Add($"Unregister:{serverHandle}");
|
||||||
UnregisteredServerHandle = serverHandle;
|
UnregisteredServerHandle = serverHandle;
|
||||||
UnregisterThreadId = Environment.CurrentManagedThreadId;
|
UnregisterThreadId = Environment.CurrentManagedThreadId;
|
||||||
|
|
||||||
@@ -738,6 +796,7 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
int serverHandle,
|
int serverHandle,
|
||||||
string itemDefinition)
|
string itemDefinition)
|
||||||
{
|
{
|
||||||
|
operationNames.Add($"AddItem:{serverHandle}:{itemDefinition}");
|
||||||
AddItemServerHandle = serverHandle;
|
AddItemServerHandle = serverHandle;
|
||||||
AddItemDefinition = itemDefinition;
|
AddItemDefinition = itemDefinition;
|
||||||
AddItemThreadId = Environment.CurrentManagedThreadId;
|
AddItemThreadId = Environment.CurrentManagedThreadId;
|
||||||
@@ -755,6 +814,7 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
string itemDefinition,
|
string itemDefinition,
|
||||||
string itemContext)
|
string itemContext)
|
||||||
{
|
{
|
||||||
|
operationNames.Add($"AddItem2:{serverHandle}:{itemDefinition}:{itemContext}");
|
||||||
AddItem2ServerHandle = serverHandle;
|
AddItem2ServerHandle = serverHandle;
|
||||||
AddItem2Definition = itemDefinition;
|
AddItem2Definition = itemDefinition;
|
||||||
AddItem2Context = itemContext;
|
AddItem2Context = itemContext;
|
||||||
@@ -772,6 +832,7 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle)
|
int itemHandle)
|
||||||
{
|
{
|
||||||
|
operationNames.Add($"RemoveItem:{serverHandle}:{itemHandle}");
|
||||||
RemoveItemServerHandle = serverHandle;
|
RemoveItemServerHandle = serverHandle;
|
||||||
RemovedItemHandle = itemHandle;
|
RemovedItemHandle = itemHandle;
|
||||||
RemoveItemThreadId = Environment.CurrentManagedThreadId;
|
RemoveItemThreadId = Environment.CurrentManagedThreadId;
|
||||||
@@ -786,6 +847,7 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle)
|
int itemHandle)
|
||||||
{
|
{
|
||||||
|
operationNames.Add($"Advise:{serverHandle}:{itemHandle}");
|
||||||
AdviseServerHandle = serverHandle;
|
AdviseServerHandle = serverHandle;
|
||||||
AdvisedItemHandle = itemHandle;
|
AdvisedItemHandle = itemHandle;
|
||||||
AdviseThreadId = Environment.CurrentManagedThreadId;
|
AdviseThreadId = Environment.CurrentManagedThreadId;
|
||||||
@@ -800,6 +862,7 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle)
|
int itemHandle)
|
||||||
{
|
{
|
||||||
|
operationNames.Add($"UnAdvise:{serverHandle}:{itemHandle}");
|
||||||
UnAdviseServerHandle = serverHandle;
|
UnAdviseServerHandle = serverHandle;
|
||||||
UnAdvisedItemHandle = itemHandle;
|
UnAdvisedItemHandle = itemHandle;
|
||||||
UnAdviseThreadId = Environment.CurrentManagedThreadId;
|
UnAdviseThreadId = Environment.CurrentManagedThreadId;
|
||||||
@@ -814,6 +877,7 @@ public sealed class MxAccessCommandExecutorTests
|
|||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle)
|
int itemHandle)
|
||||||
{
|
{
|
||||||
|
operationNames.Add($"AdviseSupervisory:{serverHandle}:{itemHandle}");
|
||||||
AdviseSupervisoryServerHandle = serverHandle;
|
AdviseSupervisoryServerHandle = serverHandle;
|
||||||
AdviseSupervisoryItemHandle = itemHandle;
|
AdviseSupervisoryItemHandle = itemHandle;
|
||||||
AdviseSupervisoryThreadId = Environment.CurrentManagedThreadId;
|
AdviseSupervisoryThreadId = Environment.CurrentManagedThreadId;
|
||||||
|
|||||||
@@ -110,6 +110,27 @@ public sealed class StaCommandDispatcherTests
|
|||||||
Assert.Equal("correlation-1", reply.CorrelationId);
|
Assert.Equal("correlation-1", reply.CorrelationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestShutdown_RejectsQueuedCommandButLetsCurrentCommandFinish()
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
|
||||||
|
dispatcher.RequestShutdown();
|
||||||
|
MxCommandReply pendingReply = await pending;
|
||||||
|
executor.Release();
|
||||||
|
MxCommandReply currentReply = await current;
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.WorkerUnavailable, pendingReply.ProtocolStatus.Code);
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, currentReply.ProtocolStatus.Code);
|
||||||
|
Assert.Equal(new[] { "current" }, executor.CorrelationIds);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PopulateHeartbeat_ReportsCurrentCorrelationAndPendingCount()
|
public async Task PopulateHeartbeat_ReportsCurrentCorrelationAndPendingCount()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
using System.IO.Pipes;
|
using System.IO.Pipes;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -11,13 +12,48 @@ public sealed class WorkerPipeClient : IWorkerPipeClient
|
|||||||
public const int DefaultConnectTimeoutMilliseconds = 30000;
|
public const int DefaultConnectTimeoutMilliseconds = 30000;
|
||||||
|
|
||||||
private readonly int _connectTimeoutMilliseconds;
|
private readonly int _connectTimeoutMilliseconds;
|
||||||
|
private readonly Func<Stream, WorkerFrameProtocolOptions, IWorkerLogger?, WorkerPipeSession> _sessionFactory;
|
||||||
|
private readonly IWorkerLogger? _logger;
|
||||||
|
|
||||||
public WorkerPipeClient()
|
public WorkerPipeClient()
|
||||||
: this(DefaultConnectTimeoutMilliseconds)
|
: this(null, DefaultConnectTimeoutMilliseconds)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkerPipeClient(IWorkerLogger? logger)
|
||||||
|
: this(logger, DefaultConnectTimeoutMilliseconds)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public WorkerPipeClient(int connectTimeoutMilliseconds)
|
public WorkerPipeClient(int connectTimeoutMilliseconds)
|
||||||
|
: this(null, connectTimeoutMilliseconds)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkerPipeClient(
|
||||||
|
int connectTimeoutMilliseconds,
|
||||||
|
Func<Stream, WorkerFrameProtocolOptions, WorkerPipeSession> sessionFactory)
|
||||||
|
: this(
|
||||||
|
null,
|
||||||
|
connectTimeoutMilliseconds,
|
||||||
|
(stream, frameOptions, _) => sessionFactory(stream, frameOptions))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkerPipeClient(
|
||||||
|
IWorkerLogger? logger,
|
||||||
|
int connectTimeoutMilliseconds)
|
||||||
|
: this(
|
||||||
|
logger,
|
||||||
|
connectTimeoutMilliseconds,
|
||||||
|
(stream, frameOptions, workerLogger) => new WorkerPipeSession(stream, frameOptions, workerLogger))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkerPipeClient(
|
||||||
|
IWorkerLogger? logger,
|
||||||
|
int connectTimeoutMilliseconds,
|
||||||
|
Func<Stream, WorkerFrameProtocolOptions, IWorkerLogger?, WorkerPipeSession> sessionFactory)
|
||||||
{
|
{
|
||||||
if (connectTimeoutMilliseconds <= 0)
|
if (connectTimeoutMilliseconds <= 0)
|
||||||
{
|
{
|
||||||
@@ -26,6 +62,8 @@ public sealed class WorkerPipeClient : IWorkerPipeClient
|
|||||||
"Worker pipe connect timeout must be greater than zero.");
|
"Worker pipe connect timeout must be greater than zero.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger = logger;
|
||||||
|
_sessionFactory = sessionFactory ?? throw new ArgumentNullException(nameof(sessionFactory));
|
||||||
_connectTimeoutMilliseconds = connectTimeoutMilliseconds;
|
_connectTimeoutMilliseconds = connectTimeoutMilliseconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,8 +86,8 @@ public sealed class WorkerPipeClient : IWorkerPipeClient
|
|||||||
|
|
||||||
await ConnectAsync(pipe, cancellationToken).ConfigureAwait(false);
|
await ConnectAsync(pipe, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
WorkerPipeSession session = new(pipe, frameOptions);
|
WorkerPipeSession session = _sessionFactory(pipe, frameOptions, _logger);
|
||||||
await session.CompleteStartupHandshakeAsync(cancellationToken).ConfigureAwait(false);
|
await session.RunAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task ConnectAsync(
|
private Task ConnectAsync(
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Worker.Bootstrap;
|
||||||
using MxGateway.Worker.MxAccess;
|
using MxGateway.Worker.MxAccess;
|
||||||
|
using MxGateway.Worker.Sta;
|
||||||
|
|
||||||
namespace MxGateway.Worker.Ipc;
|
namespace MxGateway.Worker.Ipc;
|
||||||
|
|
||||||
@@ -13,19 +16,29 @@ public sealed class WorkerPipeSession
|
|||||||
{
|
{
|
||||||
private readonly WorkerFrameProtocolOptions _options;
|
private readonly WorkerFrameProtocolOptions _options;
|
||||||
private readonly Func<int> _processIdProvider;
|
private readonly Func<int> _processIdProvider;
|
||||||
|
private readonly Func<IWorkerRuntimeSession> _runtimeSessionFactory;
|
||||||
|
private readonly WorkerPipeSessionOptions _sessionOptions;
|
||||||
|
private readonly IWorkerLogger? _logger;
|
||||||
private readonly WorkerFrameReader _reader;
|
private readonly WorkerFrameReader _reader;
|
||||||
private readonly WorkerFrameWriter _writer;
|
private readonly WorkerFrameWriter _writer;
|
||||||
private MxAccessStaSession? _mxAccessStaSession;
|
private IWorkerRuntimeSession? _runtimeSession;
|
||||||
private long _nextSequence;
|
private long _nextSequence;
|
||||||
|
private WorkerState _state = WorkerState.Starting;
|
||||||
|
private bool _watchdogFaultSent;
|
||||||
|
private bool _shutdownTimedOut;
|
||||||
|
|
||||||
public WorkerPipeSession(
|
public WorkerPipeSession(
|
||||||
Stream stream,
|
Stream stream,
|
||||||
WorkerFrameProtocolOptions options)
|
WorkerFrameProtocolOptions options,
|
||||||
|
IWorkerLogger? logger = null)
|
||||||
: this(
|
: this(
|
||||||
new WorkerFrameReader(stream, options),
|
new WorkerFrameReader(stream, options),
|
||||||
new WorkerFrameWriter(stream, options),
|
new WorkerFrameWriter(stream, options),
|
||||||
options,
|
options,
|
||||||
() => Process.GetCurrentProcess().Id)
|
() => Process.GetCurrentProcess().Id,
|
||||||
|
new WorkerPipeSessionOptions(),
|
||||||
|
() => new MxAccessStaSession(),
|
||||||
|
logger)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,11 +47,56 @@ public sealed class WorkerPipeSession
|
|||||||
WorkerFrameWriter writer,
|
WorkerFrameWriter writer,
|
||||||
WorkerFrameProtocolOptions options,
|
WorkerFrameProtocolOptions options,
|
||||||
Func<int> processIdProvider)
|
Func<int> processIdProvider)
|
||||||
|
: this(
|
||||||
|
reader,
|
||||||
|
writer,
|
||||||
|
options,
|
||||||
|
processIdProvider,
|
||||||
|
new WorkerPipeSessionOptions(),
|
||||||
|
() => new MxAccessStaSession(),
|
||||||
|
logger: null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkerPipeSession(
|
||||||
|
WorkerFrameReader reader,
|
||||||
|
WorkerFrameWriter writer,
|
||||||
|
WorkerFrameProtocolOptions options,
|
||||||
|
Func<int> processIdProvider,
|
||||||
|
WorkerPipeSessionOptions sessionOptions,
|
||||||
|
Func<IWorkerRuntimeSession> runtimeSessionFactory,
|
||||||
|
IWorkerLogger? logger = null)
|
||||||
{
|
{
|
||||||
_reader = reader ?? throw new ArgumentNullException(nameof(reader));
|
_reader = reader ?? throw new ArgumentNullException(nameof(reader));
|
||||||
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
|
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
|
||||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
_processIdProvider = processIdProvider ?? throw new ArgumentNullException(nameof(processIdProvider));
|
_processIdProvider = processIdProvider ?? throw new ArgumentNullException(nameof(processIdProvider));
|
||||||
|
_sessionOptions = sessionOptions ?? throw new ArgumentNullException(nameof(sessionOptions));
|
||||||
|
_runtimeSessionFactory = runtimeSessionFactory ?? throw new ArgumentNullException(nameof(runtimeSessionFactory));
|
||||||
|
_logger = logger;
|
||||||
|
_sessionOptions.Validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RunAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_runtimeSession = _runtimeSessionFactory();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CompleteStartupHandshakeAsync(
|
||||||
|
token => _runtimeSession.StartAsync(_options.SessionId, _processIdProvider(), token),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
await RunMessageLoopAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!_shutdownTimedOut)
|
||||||
|
{
|
||||||
|
_runtimeSession?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_runtimeSession = null;
|
||||||
|
_state = WorkerState.Stopped;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task CompleteStartupHandshakeAsync(CancellationToken cancellationToken = default)
|
public Task CompleteStartupHandshakeAsync(CancellationToken cancellationToken = default)
|
||||||
@@ -76,11 +134,14 @@ public sealed class WorkerPipeSession
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
WorkerEnvelope envelope = await _reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
WorkerEnvelope envelope = await _reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
_state = WorkerState.Handshaking;
|
||||||
ValidateGatewayHello(envelope);
|
ValidateGatewayHello(envelope);
|
||||||
|
|
||||||
await WriteWorkerHelloAsync(cancellationToken).ConfigureAwait(false);
|
await WriteWorkerHelloAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
_state = WorkerState.InitializingSta;
|
||||||
WorkerReady ready = await initializeMxAccessAsync(cancellationToken).ConfigureAwait(false);
|
WorkerReady ready = await initializeMxAccessAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await WriteWorkerReadyAsync(ready, cancellationToken).ConfigureAwait(false);
|
await WriteWorkerReadyAsync(ready, cancellationToken).ConfigureAwait(false);
|
||||||
|
_state = WorkerState.Ready;
|
||||||
}
|
}
|
||||||
catch (WorkerFrameProtocolException exception)
|
catch (WorkerFrameProtocolException exception)
|
||||||
{
|
{
|
||||||
@@ -140,6 +201,189 @@ public sealed class WorkerPipeSession
|
|||||||
return _writer.WriteAsync(CreateEnvelope(ready), cancellationToken);
|
return _writer.WriteAsync(CreateEnvelope(ready), cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task RunMessageLoopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using CancellationTokenSource heartbeatCancellation = CancellationTokenSource
|
||||||
|
.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
Task heartbeatTask = RunHeartbeatLoopAsync(heartbeatCancellation.Token);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
Task<WorkerEnvelope> readTask = _reader.ReadAsync(cancellationToken);
|
||||||
|
Task completedTask = await Task.WhenAny(readTask, heartbeatTask).ConfigureAwait(false);
|
||||||
|
if (completedTask == heartbeatTask)
|
||||||
|
{
|
||||||
|
await heartbeatTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkerEnvelope envelope = await readTask.ConfigureAwait(false);
|
||||||
|
bool keepReading = await DispatchGatewayEnvelopeAsync(envelope, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!keepReading)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
heartbeatCancellation.Cancel();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await heartbeatTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> DispatchGatewayEnvelopeAsync(
|
||||||
|
WorkerEnvelope envelope,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
switch (envelope.BodyCase)
|
||||||
|
{
|
||||||
|
case WorkerEnvelope.BodyOneofCase.WorkerCommand:
|
||||||
|
_ = ProcessCommandAsync(envelope, cancellationToken);
|
||||||
|
return true;
|
||||||
|
case WorkerEnvelope.BodyOneofCase.WorkerShutdown:
|
||||||
|
await ShutdownAsync(envelope.WorkerShutdown, cancellationToken).ConfigureAwait(false);
|
||||||
|
return false;
|
||||||
|
case WorkerEnvelope.BodyOneofCase.WorkerCancel:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
throw new WorkerFrameProtocolException(
|
||||||
|
WorkerFrameProtocolErrorCode.UnexpectedEnvelopeBody,
|
||||||
|
$"Worker received unexpected gateway envelope body {envelope.BodyCase}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessCommandAsync(
|
||||||
|
WorkerEnvelope envelope,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
IWorkerRuntimeSession runtimeSession = _runtimeSession
|
||||||
|
?? throw new InvalidOperationException("Worker runtime session has not been initialized.");
|
||||||
|
WorkerCommand workerCommand = envelope.WorkerCommand;
|
||||||
|
MxCommand command = workerCommand.Command;
|
||||||
|
StaCommand staCommand = new(
|
||||||
|
_options.SessionId,
|
||||||
|
envelope.CorrelationId,
|
||||||
|
command,
|
||||||
|
workerCommand.EnqueueTimestamp,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MxCommandReply reply = await runtimeSession.DispatchAsync(staCommand).ConfigureAwait(false);
|
||||||
|
await _writer
|
||||||
|
.WriteAsync(
|
||||||
|
CreateEnvelope(new WorkerCommandReply
|
||||||
|
{
|
||||||
|
Reply = reply,
|
||||||
|
CompletedTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||||
|
}),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_state = WorkerState.Faulted;
|
||||||
|
await TryWriteFaultAsync(
|
||||||
|
CreateFault(
|
||||||
|
WorkerFaultCategory.MxaccessCommandFailed,
|
||||||
|
staCommand.MethodName,
|
||||||
|
exception),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ShutdownAsync(
|
||||||
|
WorkerShutdown shutdown,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_state = WorkerState.ShuttingDown;
|
||||||
|
IWorkerRuntimeSession? runtimeSession = _runtimeSession;
|
||||||
|
if (runtimeSession is null)
|
||||||
|
{
|
||||||
|
await WriteShutdownAckAsync(
|
||||||
|
CreateShutdownAck(new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>()), shutdown),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeSpan gracePeriod = ResolveGracePeriod(shutdown);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MxAccessShutdownResult result = await runtimeSession
|
||||||
|
.ShutdownGracefullyAsync(gracePeriod, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
LogShutdownFailures(result.Failures);
|
||||||
|
await WriteShutdownAckAsync(CreateShutdownAck(result, shutdown), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (TimeoutException exception)
|
||||||
|
{
|
||||||
|
_shutdownTimedOut = true;
|
||||||
|
_state = WorkerState.Faulted;
|
||||||
|
await TryWriteFaultAsync(CreateShutdownTimeoutFault(exception), cancellationToken).ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task WriteShutdownAckAsync(
|
||||||
|
WorkerShutdownAck shutdownAck,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _writer.WriteAsync(CreateEnvelope(shutdownAck), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunHeartbeatLoopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await Task.Delay(_sessionOptions.HeartbeatInterval, cancellationToken).ConfigureAwait(false);
|
||||||
|
IWorkerRuntimeSession? runtimeSession = _runtimeSession;
|
||||||
|
if (runtimeSession is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkerRuntimeHeartbeatSnapshot snapshot = runtimeSession.CaptureHeartbeat();
|
||||||
|
await _writer
|
||||||
|
.WriteAsync(CreateEnvelope(CreateHeartbeat(snapshot)), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await ReportWatchdogFaultIfNeededAsync(snapshot, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReportWatchdogFaultIfNeededAsync(
|
||||||
|
WorkerRuntimeHeartbeatSnapshot snapshot,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
TimeSpan staleFor = DateTimeOffset.UtcNow - snapshot.LastStaActivityUtc;
|
||||||
|
if (staleFor <= _sessionOptions.HeartbeatGrace)
|
||||||
|
{
|
||||||
|
_watchdogFaultSent = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_watchdogFaultSent)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_watchdogFaultSent = true;
|
||||||
|
await TryWriteFaultAsync(
|
||||||
|
CreateFault(
|
||||||
|
WorkerFaultCategory.StaHung,
|
||||||
|
snapshot.CurrentCommandCorrelationId,
|
||||||
|
$"STA activity is stale by {staleFor}."),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task TryWriteFaultAsync(
|
private async Task TryWriteFaultAsync(
|
||||||
WorkerFrameProtocolException exception,
|
WorkerFrameProtocolException exception,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -178,6 +422,25 @@ public sealed class WorkerPipeSession
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task TryWriteFaultAsync(
|
||||||
|
WorkerFault fault,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _writer
|
||||||
|
.WriteAsync(CreateEnvelope(fault), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception faultWriteException) when (
|
||||||
|
faultWriteException is IOException
|
||||||
|
|| faultWriteException is ObjectDisposedException
|
||||||
|
|| faultWriteException is WorkerFrameProtocolException)
|
||||||
|
{
|
||||||
|
// The runtime fault remains observable through worker exit or pipe closure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private WorkerEnvelope CreateEnvelope(WorkerHello hello)
|
private WorkerEnvelope CreateEnvelope(WorkerHello hello)
|
||||||
{
|
{
|
||||||
return CreateBaseEnvelope(hello);
|
return CreateBaseEnvelope(hello);
|
||||||
@@ -193,6 +456,21 @@ public sealed class WorkerPipeSession
|
|||||||
return CreateBaseEnvelope(fault);
|
return CreateBaseEnvelope(fault);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private WorkerEnvelope CreateEnvelope(WorkerCommandReply reply)
|
||||||
|
{
|
||||||
|
return CreateBaseEnvelope(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkerEnvelope CreateEnvelope(WorkerShutdownAck shutdownAck)
|
||||||
|
{
|
||||||
|
return CreateBaseEnvelope(shutdownAck);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkerEnvelope CreateEnvelope(WorkerHeartbeat heartbeat)
|
||||||
|
{
|
||||||
|
return CreateBaseEnvelope(heartbeat);
|
||||||
|
}
|
||||||
|
|
||||||
private WorkerEnvelope CreateBaseEnvelope(WorkerHello body)
|
private WorkerEnvelope CreateBaseEnvelope(WorkerHello body)
|
||||||
{
|
{
|
||||||
WorkerEnvelope envelope = CreateBaseEnvelope();
|
WorkerEnvelope envelope = CreateBaseEnvelope();
|
||||||
@@ -214,6 +492,28 @@ public sealed class WorkerPipeSession
|
|||||||
return envelope;
|
return envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private WorkerEnvelope CreateBaseEnvelope(WorkerCommandReply body)
|
||||||
|
{
|
||||||
|
WorkerEnvelope envelope = CreateBaseEnvelope();
|
||||||
|
envelope.CorrelationId = body.Reply?.CorrelationId ?? string.Empty;
|
||||||
|
envelope.WorkerCommandReply = body;
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkerEnvelope CreateBaseEnvelope(WorkerShutdownAck body)
|
||||||
|
{
|
||||||
|
WorkerEnvelope envelope = CreateBaseEnvelope();
|
||||||
|
envelope.WorkerShutdownAck = body;
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkerEnvelope CreateBaseEnvelope(WorkerHeartbeat body)
|
||||||
|
{
|
||||||
|
WorkerEnvelope envelope = CreateBaseEnvelope();
|
||||||
|
envelope.WorkerHeartbeat = body;
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
private WorkerEnvelope CreateBaseEnvelope()
|
private WorkerEnvelope CreateBaseEnvelope()
|
||||||
{
|
{
|
||||||
return new WorkerEnvelope
|
return new WorkerEnvelope
|
||||||
@@ -231,21 +531,39 @@ public sealed class WorkerPipeSession
|
|||||||
|
|
||||||
private async Task<WorkerReady> InitializeMxAccessAsync(CancellationToken cancellationToken)
|
private async Task<WorkerReady> InitializeMxAccessAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_mxAccessStaSession = new MxAccessStaSession();
|
_runtimeSession = new MxAccessStaSession();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await _mxAccessStaSession
|
return await _runtimeSession
|
||||||
.StartAsync(_options.SessionId, _processIdProvider(), cancellationToken)
|
.StartAsync(_options.SessionId, _processIdProvider(), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
_mxAccessStaSession.Dispose();
|
_runtimeSession.Dispose();
|
||||||
_mxAccessStaSession = null;
|
_runtimeSession = null;
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private WorkerHeartbeat CreateHeartbeat(WorkerRuntimeHeartbeatSnapshot snapshot)
|
||||||
|
{
|
||||||
|
WorkerState state = string.IsNullOrWhiteSpace(snapshot.CurrentCommandCorrelationId)
|
||||||
|
? _state
|
||||||
|
: WorkerState.ExecutingCommand;
|
||||||
|
|
||||||
|
return new WorkerHeartbeat
|
||||||
|
{
|
||||||
|
WorkerProcessId = _processIdProvider(),
|
||||||
|
State = state,
|
||||||
|
LastStaActivityTimestamp = Timestamp.FromDateTimeOffset(snapshot.LastStaActivityUtc),
|
||||||
|
PendingCommandCount = snapshot.PendingCommandCount,
|
||||||
|
OutboundEventQueueDepth = snapshot.OutboundEventQueueDepth,
|
||||||
|
LastEventSequence = snapshot.LastEventSequence,
|
||||||
|
CurrentCommandCorrelationId = snapshot.CurrentCommandCorrelationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private WorkerReady CreateWorkerReady()
|
private WorkerReady CreateWorkerReady()
|
||||||
{
|
{
|
||||||
return new WorkerReady
|
return new WorkerReady
|
||||||
@@ -257,6 +575,57 @@ public sealed class WorkerPipeSession
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static TimeSpan ResolveGracePeriod(WorkerShutdown shutdown)
|
||||||
|
{
|
||||||
|
if (shutdown.GracePeriod is null)
|
||||||
|
{
|
||||||
|
return TimeSpan.FromSeconds(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeSpan gracePeriod = shutdown.GracePeriod.ToTimeSpan();
|
||||||
|
return gracePeriod <= TimeSpan.Zero
|
||||||
|
? TimeSpan.FromSeconds(10)
|
||||||
|
: gracePeriod;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkerShutdownAck CreateShutdownAck(
|
||||||
|
MxAccessShutdownResult result,
|
||||||
|
WorkerShutdown shutdown)
|
||||||
|
{
|
||||||
|
string message = result.Succeeded
|
||||||
|
? "Graceful shutdown completed."
|
||||||
|
: $"Graceful shutdown completed with {result.Failures.Count} cleanup failure(s).";
|
||||||
|
if (!string.IsNullOrWhiteSpace(shutdown.Reason))
|
||||||
|
{
|
||||||
|
message = $"{message} Reason: {shutdown.Reason}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WorkerShutdownAck
|
||||||
|
{
|
||||||
|
Status = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.Ok,
|
||||||
|
Message = message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LogShutdownFailures(IReadOnlyList<MxAccessShutdownFailure> failures)
|
||||||
|
{
|
||||||
|
foreach (MxAccessShutdownFailure failure in failures)
|
||||||
|
{
|
||||||
|
_logger?.Error("WorkerGracefulShutdownCleanupFailed", new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["session_id"] = _options.SessionId,
|
||||||
|
["operation"] = failure.Operation,
|
||||||
|
["server_handle"] = failure.ServerHandle,
|
||||||
|
["item_handle"] = failure.ItemHandle,
|
||||||
|
["exception_type"] = failure.ExceptionType,
|
||||||
|
["hresult"] = failure.HResult,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static WorkerFault CreateFault(WorkerFrameProtocolException exception)
|
private static WorkerFault CreateFault(WorkerFrameProtocolException exception)
|
||||||
{
|
{
|
||||||
return new WorkerFault
|
return new WorkerFault
|
||||||
@@ -295,6 +664,50 @@ public sealed class WorkerPipeSession
|
|||||||
return fault;
|
return fault;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static WorkerFault CreateFault(
|
||||||
|
WorkerFaultCategory category,
|
||||||
|
string commandMethod,
|
||||||
|
Exception exception)
|
||||||
|
{
|
||||||
|
WorkerFault fault = CreateFault(
|
||||||
|
category,
|
||||||
|
commandMethod,
|
||||||
|
exception.Message);
|
||||||
|
fault.ExceptionType = exception.GetType().FullName ?? string.Empty;
|
||||||
|
fault.ProtocolStatus = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.WorkerUnavailable,
|
||||||
|
Message = exception.Message,
|
||||||
|
};
|
||||||
|
return fault;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkerFault CreateFault(
|
||||||
|
WorkerFaultCategory category,
|
||||||
|
string commandMethod,
|
||||||
|
string diagnosticMessage)
|
||||||
|
{
|
||||||
|
return new WorkerFault
|
||||||
|
{
|
||||||
|
Category = category,
|
||||||
|
CommandMethod = commandMethod ?? string.Empty,
|
||||||
|
DiagnosticMessage = diagnosticMessage,
|
||||||
|
ProtocolStatus = new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.WorkerUnavailable,
|
||||||
|
Message = diagnosticMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkerFault CreateShutdownTimeoutFault(TimeoutException exception)
|
||||||
|
{
|
||||||
|
return CreateFault(
|
||||||
|
WorkerFaultCategory.ShutdownTimeout,
|
||||||
|
commandMethod: string.Empty,
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
|
||||||
private static WorkerFaultCategory MapFaultCategory(WorkerFrameProtocolErrorCode errorCode)
|
private static WorkerFaultCategory MapFaultCategory(WorkerFrameProtocolErrorCode errorCode)
|
||||||
{
|
{
|
||||||
return errorCode switch
|
return errorCode switch
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.Ipc;
|
||||||
|
|
||||||
|
public sealed class WorkerPipeSessionOptions
|
||||||
|
{
|
||||||
|
public static readonly TimeSpan DefaultHeartbeatInterval = TimeSpan.FromSeconds(5);
|
||||||
|
public static readonly TimeSpan DefaultHeartbeatGrace = TimeSpan.FromSeconds(15);
|
||||||
|
|
||||||
|
public WorkerPipeSessionOptions()
|
||||||
|
{
|
||||||
|
HeartbeatInterval = DefaultHeartbeatInterval;
|
||||||
|
HeartbeatGrace = DefaultHeartbeatGrace;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimeSpan HeartbeatInterval { get; set; }
|
||||||
|
|
||||||
|
public TimeSpan HeartbeatGrace { get; set; }
|
||||||
|
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
if (HeartbeatInterval <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(HeartbeatInterval),
|
||||||
|
"Worker heartbeat interval must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HeartbeatGrace <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(HeartbeatGrace),
|
||||||
|
"Worker heartbeat grace must be greater than zero.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Worker.Sta;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
public interface IWorkerRuntimeSession : IDisposable
|
||||||
|
{
|
||||||
|
Task<WorkerReady> StartAsync(
|
||||||
|
string sessionId,
|
||||||
|
int workerProcessId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<MxCommandReply> DispatchAsync(StaCommand command);
|
||||||
|
|
||||||
|
WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat();
|
||||||
|
|
||||||
|
void RequestShutdown();
|
||||||
|
|
||||||
|
Task<MxAccessShutdownResult> ShutdownGracefullyAsync(
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
@@ -188,6 +189,23 @@ public sealed class MxAccessSession : IDisposable
|
|||||||
MxAccessAdviceKind.Supervisory);
|
MxAccessAdviceKind.Supervisory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public MxAccessShutdownResult ShutdownGracefully()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
return new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MxAccessShutdownFailure> failures = new();
|
||||||
|
|
||||||
|
CleanupAdviceHandles(failures);
|
||||||
|
CleanupItemHandles(failures);
|
||||||
|
CleanupServerHandles(failures);
|
||||||
|
DisposeCore(failures);
|
||||||
|
|
||||||
|
return new MxAccessShutdownResult(failures);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (disposed)
|
if (disposed)
|
||||||
@@ -195,11 +213,112 @@ public sealed class MxAccessSession : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
eventSink.Detach();
|
DisposeCore(failures: null);
|
||||||
|
}
|
||||||
|
|
||||||
if (Marshal.IsComObject(mxAccessComObject))
|
private void CleanupAdviceHandles(ICollection<MxAccessShutdownFailure> failures)
|
||||||
|
{
|
||||||
|
HashSet<long> cleanedPairs = new();
|
||||||
|
foreach (RegisteredAdviceHandle adviceHandle in handleRegistry.AdviceHandles)
|
||||||
{
|
{
|
||||||
Marshal.FinalReleaseComObject(mxAccessComObject);
|
long key = CreateItemKey(adviceHandle.ServerHandle, adviceHandle.ItemHandle);
|
||||||
|
if (!cleanedPairs.Add(key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mxAccessServer.UnAdvise(adviceHandle.ServerHandle, adviceHandle.ItemHandle);
|
||||||
|
handleRegistry.RemoveAdviceHandles(adviceHandle.ServerHandle, adviceHandle.ItemHandle);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
failures.Add(new MxAccessShutdownFailure(
|
||||||
|
nameof(UnAdvise),
|
||||||
|
adviceHandle.ServerHandle,
|
||||||
|
adviceHandle.ItemHandle,
|
||||||
|
exception));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanupItemHandles(ICollection<MxAccessShutdownFailure> failures)
|
||||||
|
{
|
||||||
|
foreach (RegisteredItemHandle itemHandle in handleRegistry.ItemHandles)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mxAccessServer.RemoveItem(itemHandle.ServerHandle, itemHandle.ItemHandle);
|
||||||
|
handleRegistry.RemoveItemHandle(itemHandle.ServerHandle, itemHandle.ItemHandle);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
failures.Add(new MxAccessShutdownFailure(
|
||||||
|
nameof(RemoveItem),
|
||||||
|
itemHandle.ServerHandle,
|
||||||
|
itemHandle.ItemHandle,
|
||||||
|
exception));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanupServerHandles(ICollection<MxAccessShutdownFailure> failures)
|
||||||
|
{
|
||||||
|
foreach (RegisteredServerHandle serverHandle in handleRegistry.ServerHandles)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mxAccessServer.Unregister(serverHandle.ServerHandle);
|
||||||
|
handleRegistry.UnregisterServerHandle(serverHandle.ServerHandle);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
failures.Add(new MxAccessShutdownFailure(
|
||||||
|
nameof(Unregister),
|
||||||
|
serverHandle.ServerHandle,
|
||||||
|
itemHandle: null,
|
||||||
|
exception));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long CreateItemKey(
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle)
|
||||||
|
{
|
||||||
|
return ((long)serverHandle << 32) | (uint)itemHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisposeCore(ICollection<MxAccessShutdownFailure>? failures)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
eventSink.Detach();
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (failures is not null)
|
||||||
|
{
|
||||||
|
failures.Add(new MxAccessShutdownFailure(
|
||||||
|
"DetachEvents",
|
||||||
|
serverHandle: null,
|
||||||
|
itemHandle: null,
|
||||||
|
exception));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Marshal.IsComObject(mxAccessComObject))
|
||||||
|
{
|
||||||
|
Marshal.FinalReleaseComObject(mxAccessComObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (failures is not null)
|
||||||
|
{
|
||||||
|
failures.Add(new MxAccessShutdownFailure(
|
||||||
|
"ReleaseComObject",
|
||||||
|
serverHandle: null,
|
||||||
|
itemHandle: null,
|
||||||
|
exception));
|
||||||
}
|
}
|
||||||
|
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
public sealed class MxAccessShutdownFailure
|
||||||
|
{
|
||||||
|
public MxAccessShutdownFailure(
|
||||||
|
string operation,
|
||||||
|
int? serverHandle,
|
||||||
|
int? itemHandle,
|
||||||
|
Exception exception)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(operation))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Shutdown failure operation is required.", nameof(operation));
|
||||||
|
}
|
||||||
|
|
||||||
|
Operation = operation;
|
||||||
|
ServerHandle = serverHandle;
|
||||||
|
ItemHandle = itemHandle;
|
||||||
|
ExceptionType = exception?.GetType().FullName ?? string.Empty;
|
||||||
|
HResult = exception?.HResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Operation { get; }
|
||||||
|
|
||||||
|
public int? ServerHandle { get; }
|
||||||
|
|
||||||
|
public int? ItemHandle { get; }
|
||||||
|
|
||||||
|
public string ExceptionType { get; }
|
||||||
|
|
||||||
|
public int? HResult { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
public sealed class MxAccessShutdownResult
|
||||||
|
{
|
||||||
|
public MxAccessShutdownResult(IReadOnlyList<MxAccessShutdownFailure> failures)
|
||||||
|
{
|
||||||
|
Failures = failures ?? throw new ArgumentNullException(nameof(failures));
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<MxAccessShutdownFailure> Failures { get; }
|
||||||
|
|
||||||
|
public bool Succeeded => Failures.Count == 0;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MxGateway.Contracts.Proto;
|
using MxGateway.Contracts.Proto;
|
||||||
@@ -7,7 +8,7 @@ using MxGateway.Worker.Sta;
|
|||||||
|
|
||||||
namespace MxGateway.Worker.MxAccess;
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
public sealed class MxAccessStaSession : IDisposable
|
public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||||
{
|
{
|
||||||
private readonly IMxAccessComObjectFactory factory;
|
private readonly IMxAccessComObjectFactory factory;
|
||||||
private readonly IMxAccessEventSink eventSink;
|
private readonly IMxAccessEventSink eventSink;
|
||||||
@@ -97,6 +98,30 @@ public sealed class MxAccessStaSession : IDisposable
|
|||||||
return commandDispatcher.DispatchAsync(command);
|
return commandDispatcher.DispatchAsync(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat()
|
||||||
|
{
|
||||||
|
uint pendingCommandCount = 0;
|
||||||
|
string currentCommandCorrelationId = string.Empty;
|
||||||
|
|
||||||
|
if (commandDispatcher is not null)
|
||||||
|
{
|
||||||
|
pendingCommandCount = (uint)commandDispatcher.PendingCommandCount;
|
||||||
|
currentCommandCorrelationId = commandDispatcher.CurrentCommandCorrelationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WorkerRuntimeHeartbeatSnapshot(
|
||||||
|
staRuntime.LastActivityUtc,
|
||||||
|
pendingCommandCount,
|
||||||
|
(uint)eventQueue.Count,
|
||||||
|
eventQueue.LastEventSequence,
|
||||||
|
currentCommandCorrelationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RequestShutdown()
|
||||||
|
{
|
||||||
|
commandDispatcher?.RequestShutdown();
|
||||||
|
}
|
||||||
|
|
||||||
public IReadOnlyList<WorkerEvent> DrainEvents(uint maxEvents)
|
public IReadOnlyList<WorkerEvent> DrainEvents(uint maxEvents)
|
||||||
{
|
{
|
||||||
return eventQueue.Drain(maxEvents);
|
return eventQueue.Drain(maxEvents);
|
||||||
@@ -141,6 +166,61 @@ public sealed class MxAccessStaSession : IDisposable
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<MxAccessShutdownResult> ShutdownGracefullyAsync(
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (timeout <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(timeout),
|
||||||
|
"MXAccess graceful shutdown timeout must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
return new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>());
|
||||||
|
}
|
||||||
|
|
||||||
|
commandDispatcher?.RequestShutdown();
|
||||||
|
|
||||||
|
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||||
|
MxAccessShutdownResult result;
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
result = new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
using CancellationTokenSource shutdownCancellation =
|
||||||
|
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
shutdownCancellation.CancelAfter(timeout);
|
||||||
|
|
||||||
|
Task<MxAccessShutdownResult> cleanupTask = staRuntime.InvokeAsync(
|
||||||
|
() => session.ShutdownGracefully(),
|
||||||
|
shutdownCancellation.Token);
|
||||||
|
Task delayTask = Task.Delay(timeout, cancellationToken);
|
||||||
|
Task completedTask = await Task.WhenAny(cleanupTask, delayTask).ConfigureAwait(false);
|
||||||
|
if (completedTask != cleanupTask)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
throw new TimeoutException($"MXAccess graceful shutdown exceeded {timeout}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await cleanupTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeSpan remaining = timeout - stopwatch.Elapsed;
|
||||||
|
if (remaining <= TimeSpan.Zero || !staRuntime.Shutdown(remaining))
|
||||||
|
{
|
||||||
|
throw new TimeoutException($"MXAccess graceful shutdown exceeded {timeout}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
staRuntime.Dispose();
|
||||||
|
disposed = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (disposed)
|
if (disposed)
|
||||||
@@ -148,7 +228,7 @@ public sealed class MxAccessStaSession : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
commandDispatcher?.RequestShutdown();
|
RequestShutdown();
|
||||||
|
|
||||||
if (session is not null)
|
if (session is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace MxGateway.Worker.MxAccess;
|
||||||
|
|
||||||
|
public sealed class WorkerRuntimeHeartbeatSnapshot
|
||||||
|
{
|
||||||
|
public WorkerRuntimeHeartbeatSnapshot(
|
||||||
|
DateTimeOffset lastStaActivityUtc,
|
||||||
|
uint pendingCommandCount,
|
||||||
|
uint outboundEventQueueDepth,
|
||||||
|
ulong lastEventSequence,
|
||||||
|
string currentCommandCorrelationId)
|
||||||
|
{
|
||||||
|
LastStaActivityUtc = lastStaActivityUtc;
|
||||||
|
PendingCommandCount = pendingCommandCount;
|
||||||
|
OutboundEventQueueDepth = outboundEventQueueDepth;
|
||||||
|
LastEventSequence = lastEventSequence;
|
||||||
|
CurrentCommandCorrelationId = currentCommandCorrelationId ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTimeOffset LastStaActivityUtc { get; }
|
||||||
|
|
||||||
|
public uint PendingCommandCount { get; }
|
||||||
|
|
||||||
|
public uint OutboundEventQueueDepth { get; }
|
||||||
|
|
||||||
|
public ulong LastEventSequence { get; }
|
||||||
|
|
||||||
|
public string CurrentCommandCorrelationId { get; }
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user