Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7929cc12f | |||
| d890eff862 | |||
| 9dcd4baff2 | |||
| 7a0743496f | |||
| bcfbd1cfc8 | |||
| 8e3b0c1c4a | |||
| bd4be85f26 | |||
| 7331c6157a | |||
| cbc317e3e7 | |||
| 7242cf772b | |||
| 7d67313a7d | |||
| 044b16c5db | |||
| 1f92078777 | |||
| 4a3560c7ee | |||
| 108a3d3f8a | |||
| 95e71cd819 | |||
| 647fe9a4b5 | |||
| dd455089b4 | |||
| d0bc4e3c01 | |||
| 6a40d26366 | |||
| 366f57198f | |||
| aab41e04ab | |||
| 3be92a17bd | |||
| a871f2f2e5 |
@@ -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,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,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,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,41 @@
|
||||
using Grpc.Net.Client;
|
||||
using MxGateway.Contracts.Proto;
|
||||
|
||||
namespace MxGateway.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Provides the initial .NET client entry point and raw generated gRPC client.
|
||||
/// </summary>
|
||||
public sealed class MxGatewayClient : IAsyncDisposable
|
||||
{
|
||||
private readonly GrpcChannel _channel;
|
||||
|
||||
private MxGatewayClient(GrpcChannel channel)
|
||||
{
|
||||
_channel = channel;
|
||||
RawClient = new MxAccessGateway.MxAccessGatewayClient(channel);
|
||||
}
|
||||
|
||||
public MxAccessGateway.MxAccessGatewayClient RawClient { get; }
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_channel.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -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,24 @@
|
||||
# .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 and raw gRPC client access. |
|
||||
| `MxGateway.Client.Cli` | Test CLI for smoke and diagnostic commands. |
|
||||
| `MxGateway.Client.Tests` | Unit tests for the scaffold and generated contract wiring. |
|
||||
|
||||
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
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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 ./...
|
||||
```
|
||||
|
||||
The scaffold tests parse the shared golden JSON fixtures with the generated Go
|
||||
types. Later client implementation tests add fake gRPC services, auth metadata,
|
||||
streaming, value conversion, and CLI behavior.
|
||||
|
||||
## CLI
|
||||
|
||||
The scaffold CLI exposes version information:
|
||||
|
||||
```powershell
|
||||
go run ./cmd/mxgw-go version -json
|
||||
```
|
||||
|
||||
Additional commands are implemented with the client/session wrapper work.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
|
||||
)
|
||||
|
||||
type versionOutput struct {
|
||||
ClientVersion string `json:"clientVersion"`
|
||||
GatewayProtocolVersion uint32 `json:"gatewayProtocolVersion"`
|
||||
WorkerProtocolVersion uint32 `json:"workerProtocolVersion"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: mxgw-go version [-json]")
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "version":
|
||||
return runVersion(args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runVersion(args []string) error {
|
||||
flags := flag.NewFlagSet("version", flag.ContinueOnError)
|
||||
flags.SetOutput(os.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 {
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(output)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "mxgw-go %s\n", output.ClientVersion)
|
||||
fmt.Fprintf(os.Stdout, "gateway protocol %d\n", output.GatewayProtocolVersion)
|
||||
fmt.Fprintf(os.Stdout, "worker protocol %d\n", output.WorkerProtocolVersion)
|
||||
return nil
|
||||
}
|
||||
@@ -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=
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
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,33 @@
|
||||
package mxgateway
|
||||
|
||||
import "strings"
|
||||
|
||||
// Options configures future gateway connections.
|
||||
type Options struct {
|
||||
Endpoint string
|
||||
APIKey string
|
||||
Plaintext bool
|
||||
CACertFile string
|
||||
ServerNameOverride string
|
||||
}
|
||||
|
||||
// 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,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 @@
|
||||
|
||||
Binary file not shown.
@@ -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="
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"sessionId": "session-fixture",
|
||||
"backendName": "mxaccess-worker",
|
||||
"workerProcessId": 1234,
|
||||
"workerProtocolVersion": 1,
|
||||
"gatewayProtocolVersion": 1,
|
||||
"capabilities": [
|
||||
"unary-open-session",
|
||||
"unary-close-session",
|
||||
"unary-invoke",
|
||||
"server-stream-events"
|
||||
],
|
||||
"defaultCommandTimeout": "30s",
|
||||
"protocolStatus": {
|
||||
"code": "PROTOCOL_STATUS_CODE_OK",
|
||||
"message": "Session opened."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"sessionId": "session-fixture",
|
||||
"clientCorrelationId": "fixture-register-1",
|
||||
"command": {
|
||||
"kind": "MX_COMMAND_KIND_REGISTER",
|
||||
"register": {
|
||||
"clientName": "fixture-client"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"contractName": "mxaccess-gateway",
|
||||
"gatewayProtocolVersion": 1,
|
||||
"workerProtocolVersion": 1,
|
||||
"protoRoot": "src/MxGateway.Contracts/Protos",
|
||||
"sourceFiles": [
|
||||
{
|
||||
"path": "mxaccess_gateway.proto",
|
||||
"role": "public_gateway"
|
||||
},
|
||||
{
|
||||
"path": "mxaccess_worker.proto",
|
||||
"role": "gateway_worker_ipc"
|
||||
}
|
||||
],
|
||||
"descriptorSet": "clients/proto/descriptors/mxaccessgw-client-v1.protoset",
|
||||
"fixtureRoot": "clients/proto/fixtures/golden",
|
||||
"behaviorFixtureRoot": "clients/proto/fixtures/behavior",
|
||||
"generatedOutputs": {
|
||||
"dotnet": "clients/dotnet/generated",
|
||||
"go": "clients/go/internal/generated",
|
||||
"rust": "clients/rust/src/generated",
|
||||
"python": "clients/python/src/mxgateway/generated",
|
||||
"java": "clients/java/src/main/generated"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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,12 @@ event, value, and status shapes.
|
||||
Generated C# output is written to `src/MxGateway.Contracts/Generated/`. Do not
|
||||
hand-edit generated files.
|
||||
|
||||
Client generation inputs are published through
|
||||
`clients/proto/proto-inputs.json` and the descriptor set under
|
||||
`clients/proto/descriptors/`. See
|
||||
[Client Proto Generation](./client-proto-generation.md) for language-specific
|
||||
generation inputs, output directories, and golden protobuf JSON fixtures.
|
||||
|
||||
## Generation
|
||||
|
||||
Run the contracts build to regenerate C# protobuf and gRPC code:
|
||||
@@ -39,8 +45,15 @@ gateway and test projects:
|
||||
dotnet build src/MxGateway.sln
|
||||
```
|
||||
|
||||
Regenerate the client descriptor after changing either `.proto` file:
|
||||
|
||||
```bash
|
||||
powershell -ExecutionPolicy Bypass -File scripts/publish-client-proto-inputs.ps1
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Client Proto Generation](./client-proto-generation.md)
|
||||
- [Gateway Process Detailed Design](./gateway-process-design.md)
|
||||
- [MXAccess Worker Instance Detailed Design](./mxaccess-worker-instance-design.md)
|
||||
- [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md)
|
||||
|
||||
@@ -18,6 +18,7 @@ starting `MxGateway.Worker.exe` or loading MXAccess COM. The harness scripts:
|
||||
- `WorkerHello` and `WorkerReady` startup,
|
||||
- command replies with matching correlation ids,
|
||||
- ordered `WorkerEvent` frames,
|
||||
- `WorkerHeartbeat` frames,
|
||||
- `WorkerFault` frames,
|
||||
- shutdown acknowledgements,
|
||||
- malformed protobuf payloads and oversized frame headers,
|
||||
@@ -43,6 +44,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~SessionWorkerClientFactoryFakeWorkerTests
|
||||
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:
|
||||
|
||||
@@ -26,6 +26,12 @@ Language-specific plans:
|
||||
- `docs/clients-python-design.md`
|
||||
- `docs/clients-java-design.md`
|
||||
|
||||
Shared generation inputs:
|
||||
|
||||
- `docs/client-proto-generation.md`
|
||||
- `docs/ClientBehaviorFixtures.md`
|
||||
- `clients/proto/proto-inputs.json`
|
||||
|
||||
Language style guides:
|
||||
|
||||
| Client | Style guide |
|
||||
@@ -305,6 +311,11 @@ CLI output should support JSON for automated tests.
|
||||
Unit tests must run without a live gateway. Use fake gRPC services, mock
|
||||
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:
|
||||
|
||||
- options parsing,
|
||||
@@ -365,6 +376,16 @@ examples/
|
||||
Generated code should be reproducible from `src/MxGateway.Contracts/Protos/`.
|
||||
Do not hand-edit generated code.
|
||||
|
||||
The stable client proto manifest defines the generated-code directories:
|
||||
|
||||
```text
|
||||
clients/dotnet/generated
|
||||
clients/go/internal/generated
|
||||
clients/rust/src/generated
|
||||
clients/python/src/mxgateway/generated
|
||||
clients/java/src/main/generated
|
||||
```
|
||||
|
||||
## Versioning
|
||||
|
||||
All clients should expose:
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# Client Proto Generation
|
||||
|
||||
This document defines the stable protobuf inputs that official clients use to
|
||||
generate language-specific gRPC bindings. The checked-in `.proto` files remain
|
||||
the source of truth so clients do not drift from the gateway and worker
|
||||
contracts.
|
||||
|
||||
## Stable Inputs
|
||||
|
||||
The stable client input manifest is `clients/proto/proto-inputs.json`. It
|
||||
records:
|
||||
|
||||
- the public gateway protocol version,
|
||||
- the worker IPC protocol version,
|
||||
- the protobuf import root,
|
||||
- the public and worker source files,
|
||||
- the descriptor set path,
|
||||
- golden fixture locations,
|
||||
- behavior fixture locations,
|
||||
- generated-code output directories for each planned client.
|
||||
|
||||
The source files listed by the manifest are:
|
||||
|
||||
- `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto`
|
||||
- `src/MxGateway.Contracts/Protos/mxaccess_worker.proto`
|
||||
|
||||
`mxaccess_gateway.proto` defines the public gRPC service and shared DTOs.
|
||||
`mxaccess_worker.proto` is included in the descriptor because worker-aware
|
||||
tests and fake-worker clients need the same command, reply, event, value, and
|
||||
status shapes.
|
||||
|
||||
## Protocol Version
|
||||
|
||||
`GatewayContractInfo.GatewayProtocolVersion` is the public gateway protocol
|
||||
version. `OpenSessionReply.gateway_protocol_version` returns the same value so
|
||||
clients can compare their generated bindings against the gateway before issuing
|
||||
MXAccess commands.
|
||||
|
||||
`GatewayContractInfo.WorkerProtocolVersion` remains the gateway-to-worker IPC
|
||||
protocol version. It is also present in `OpenSessionReply` because parity
|
||||
fixtures and fake-worker tests need to know the worker contract used by the
|
||||
session.
|
||||
|
||||
## Descriptor Publishing
|
||||
|
||||
Run this command after changing either source `.proto` file or the client proto
|
||||
manifest:
|
||||
|
||||
```powershell
|
||||
scripts/publish-client-proto-inputs.ps1
|
||||
```
|
||||
|
||||
The script writes
|
||||
`clients/proto/descriptors/mxaccessgw-client-v1.protoset` with imports and
|
||||
source information included. The descriptor is a generated artifact; do not edit
|
||||
it by hand.
|
||||
|
||||
Use the check mode in CI or before committing:
|
||||
|
||||
```powershell
|
||||
scripts/publish-client-proto-inputs.ps1 -Check
|
||||
```
|
||||
|
||||
`-Check` rebuilds the descriptor in a temporary path and fails when the checked
|
||||
in descriptor is stale.
|
||||
|
||||
## Output Directories
|
||||
|
||||
The manifest declares these generated-code directories:
|
||||
|
||||
| Client | Directory |
|
||||
|--------|-----------|
|
||||
| .NET | `clients/dotnet/generated` |
|
||||
| Go | `clients/go/internal/generated` |
|
||||
| Rust | `clients/rust/src/generated` |
|
||||
| Python | `clients/python/src/mxgateway/generated` |
|
||||
| Java | `clients/java/src/main/generated` |
|
||||
|
||||
Only generator output belongs in these directories. Handwritten client wrappers
|
||||
belong in the language-specific source trees created by the client scaffold
|
||||
issues.
|
||||
|
||||
## Language Generation Inputs
|
||||
|
||||
All generators use `src/MxGateway.Contracts/Protos` as the protobuf import
|
||||
root. The checked-in descriptor is available when a language build prefers a
|
||||
descriptor input, but the `.proto` files remain canonical.
|
||||
|
||||
.NET generation currently runs through the contracts project:
|
||||
|
||||
```powershell
|
||||
dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj
|
||||
```
|
||||
|
||||
Future .NET client projects may either reference `MxGateway.Contracts` or
|
||||
generate client-local files into `clients/dotnet/generated` with `Grpc.Tools`.
|
||||
|
||||
Go clients should generate `mxaccess_gateway.proto` and
|
||||
`mxaccess_worker.proto` into `clients/go/internal/generated` with
|
||||
`protoc-gen-go` and `protoc-gen-go-grpc`. Keep generated packages internal
|
||||
unless the wrapper API intentionally exposes raw protobuf messages.
|
||||
|
||||
The Go scaffold provides a repo-local generation script:
|
||||
|
||||
```powershell
|
||||
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 should use `tonic-build` or the selected protobuf generator from
|
||||
the Rust client build script, with generated modules placed under
|
||||
`clients/rust/src/generated` or included from the build output according to the
|
||||
client crate design.
|
||||
|
||||
Python clients should use `grpc_tools.protoc` and write generated modules under
|
||||
`clients/python/src/mxgateway/generated` so imports stay separate from
|
||||
handwritten async wrappers.
|
||||
|
||||
Java clients should use the Gradle protobuf plugin and write generated sources
|
||||
under `clients/java/src/main/generated`. The Java client scaffold owns the
|
||||
Gradle plugin versions and source-set wiring.
|
||||
|
||||
## Golden Fixtures
|
||||
|
||||
Golden protobuf JSON fixtures live in `clients/proto/fixtures/golden`. They
|
||||
exercise payloads that every language client must parse:
|
||||
|
||||
- `open-session-reply.ok.json`
|
||||
- `register-command-request.json`
|
||||
- `on-data-change-event.json`
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
- [Protobuf Contracts](./Contracts.md)
|
||||
- [Client Libraries Detailed Design](./client-libraries-design.md)
|
||||
- [Client Behavior Fixtures](./ClientBehaviorFixtures.md)
|
||||
- [Client Libraries Implementation Plan](./implementation-plan-clients.md)
|
||||
- [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md)
|
||||
@@ -16,6 +16,7 @@ Recommended layout:
|
||||
|
||||
```text
|
||||
clients/dotnet/
|
||||
MxGateway.Client.sln
|
||||
MxGateway.Client/
|
||||
MxGateway.Client.csproj
|
||||
GatewayClient.cs
|
||||
@@ -41,6 +42,12 @@ Target framework:
|
||||
<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:
|
||||
|
||||
- `Grpc.Net.Client`
|
||||
|
||||
@@ -175,6 +175,12 @@ Behavior:
|
||||
`CloseSession` should be idempotent. Closing an already closed session should
|
||||
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` 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
|
||||
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
|
||||
|
||||
Each command gets:
|
||||
|
||||
@@ -277,6 +277,8 @@ Live tests:
|
||||
|
||||
Labels: `area:worker`, `type:feature`, `priority:p0`
|
||||
|
||||
Status: implemented.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- handlers for `OnDataChange`,
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
The worker must subscribe to every public MXAccess event family:
|
||||
@@ -348,9 +355,28 @@ Event handling rules:
|
||||
- Enqueue to the outbound event queue.
|
||||
- Return quickly to preserve message pumping.
|
||||
|
||||
If event conversion throws, catch it inside the event handler, enqueue a
|
||||
structured `WorkerFault` or diagnostic event, and keep the worker alive only if
|
||||
the fault policy allows it.
|
||||
`MxAccessBaseEventSink` implements the COM connection-point handlers and keeps
|
||||
the handlers limited to event argument conversion plus enqueue. It uses
|
||||
`MxAccessEventMapper` to create `MxEvent` DTOs for `OnDataChange`,
|
||||
`OnWriteComplete`, `OperationComplete`, and `OnBufferedDataChange`. The mapper
|
||||
converts scalar and array values through `VariantConverter`, converts
|
||||
`MXSTATUS_PROXY[]` through `MxStatusProxyConverter`, and maps installed
|
||||
`MxDataType` values to the public protobuf enum while preserving the raw data
|
||||
type on buffered events. `OperationComplete` is only emitted from the native
|
||||
`OperationComplete` handler; write completion does not synthesize it.
|
||||
|
||||
`MxAccessEventQueue` is the bounded outbound event queue for one worker
|
||||
session. It assigns the monotonic `WorkerSequence` and `WorkerTimestamp` when an
|
||||
event is accepted, preserving the order in which MXAccess handlers enqueue
|
||||
events. The default capacity is `10000`. When the queue reaches capacity it
|
||||
records a `WorkerFaultCategory.QueueOverflow` fault and rejects further events.
|
||||
The event handler catches conversion and enqueue failures, records the first
|
||||
fault on the queue, and returns to the STA message pump instead of writing to
|
||||
the pipe.
|
||||
|
||||
If event conversion throws, catch it inside the event handler, record a
|
||||
structured `WorkerFault`, and keep the worker alive only if the fault policy
|
||||
allows it.
|
||||
|
||||
## Command Queue
|
||||
|
||||
@@ -451,6 +477,26 @@ cross-server handle behavior remains owned by MXAccess. COM exceptions continue
|
||||
through `StaCommandDispatcher`, which preserves the HRESULT and leaves
|
||||
diagnostic registry state unchanged for failed cleanup calls.
|
||||
|
||||
`MxAccessCommandExecutor` implements advice lifecycle commands on the same STA
|
||||
path:
|
||||
|
||||
- `Advise` calls `LMXProxyServerClass.Advise` with the requested server handle
|
||||
and item handle.
|
||||
- `AdviseSupervisory` calls `LMXProxyServerClass.AdviseSupervisory` with the
|
||||
requested server handle and item handle. This remains a distinct command from
|
||||
plain `Advise` even though observed scalar captures share the same lower-level
|
||||
subscription body.
|
||||
- `UnAdvise` calls `LMXProxyServerClass.UnAdvise` with the requested server
|
||||
handle and item handle.
|
||||
|
||||
The worker records plain and supervisory advice separately only after the COM
|
||||
call returns normally. Successful `UnAdvise` removes all tracked advice for the
|
||||
server and item pair because the public MXAccess cleanup method has no plain
|
||||
versus supervisory selector. Successful `RemoveItem` and `Unregister` also clear
|
||||
related advice state from the worker registry. Failed advice and cleanup calls
|
||||
leave registry state unchanged so diagnostics continue to reflect the last
|
||||
successful MXAccess-owned state transition.
|
||||
|
||||
## Handle Registry
|
||||
|
||||
The worker should track MXAccess state for diagnostics and cleanup, while still
|
||||
@@ -475,6 +521,9 @@ Rules:
|
||||
- Remove server handles only after `Unregister` succeeds.
|
||||
- Record item handles only after `AddItem` or `AddItem2` succeeds.
|
||||
- Remove item handles only after `RemoveItem` succeeds.
|
||||
- Record advice state only after `Advise` or `AdviseSupervisory` succeeds.
|
||||
- Remove advice state only after `UnAdvise`, `RemoveItem`, or `Unregister`
|
||||
succeeds.
|
||||
- Preserve invalid-handle behavior from MXAccess.
|
||||
- Preserve cross-server handle behavior from MXAccess.
|
||||
- Use registry state for cleanup and diagnostics, not semantic correction.
|
||||
@@ -576,13 +625,19 @@ Do not drop or coalesce events in v1.
|
||||
|
||||
## 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,
|
||||
- worker host is alive,
|
||||
- STA has recently pumped or completed work.
|
||||
|
||||
Heartbeat payload should include:
|
||||
Heartbeat payload includes:
|
||||
|
||||
- worker process id,
|
||||
- session id,
|
||||
@@ -593,13 +648,19 @@ Heartbeat payload should include:
|
||||
- event sequence,
|
||||
- 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 has not pumped messages within the heartbeat grace period,
|
||||
- event queue depth remains high.
|
||||
|
||||
The worker can report the problem, but the gateway owns the final kill decision.
|
||||
The STA watchdog currently emits a `WorkerFault` with
|
||||
`WorkerFaultCategory.StaHung` when `LastStaActivityUtc` is older than
|
||||
`WorkerPipeSessionOptions.HeartbeatGrace`. The fault includes the current
|
||||
command correlation id when a command is active. Command duration and high event
|
||||
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
|
||||
|
||||
@@ -621,6 +682,29 @@ Graceful shutdown sequence:
|
||||
If shutdown wedges, the gateway kills the process. The worker should be written
|
||||
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
|
||||
|
||||
Worker fault categories:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Check
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
$protoRoot = Join-Path $repoRoot "src/MxGateway.Contracts/Protos"
|
||||
$manifestPath = Join-Path $repoRoot "clients/proto/proto-inputs.json"
|
||||
$descriptorPath = Join-Path $repoRoot "clients/proto/descriptors/mxaccessgw-client-v1.protoset"
|
||||
|
||||
function Resolve-Protoc {
|
||||
$pathCommand = Get-Command "protoc.exe" -ErrorAction SilentlyContinue
|
||||
if ($null -ne $pathCommand) {
|
||||
return $pathCommand.Source
|
||||
}
|
||||
|
||||
$documentedPath = Join-Path $env:LOCALAPPDATA "Microsoft/WinGet/Packages/Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe/bin/protoc.exe"
|
||||
if (Test-Path $documentedPath) {
|
||||
return $documentedPath
|
||||
}
|
||||
|
||||
throw "Could not find protoc.exe. See docs/toolchain-links.md for the documented protobuf toolchain path."
|
||||
}
|
||||
|
||||
function Ensure-Directory {
|
||||
param([string]$Path)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
New-Item -ItemType Directory -Path $Path | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
function Compare-FileBytes {
|
||||
param(
|
||||
[string]$ExpectedPath,
|
||||
[string]$ActualPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path $ExpectedPath)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$expected = [System.IO.File]::ReadAllBytes($ExpectedPath)
|
||||
$actual = [System.IO.File]::ReadAllBytes($ActualPath)
|
||||
if ($expected.Length -ne $actual.Length) {
|
||||
return $false
|
||||
}
|
||||
|
||||
for ($index = 0; $index -lt $expected.Length; $index++) {
|
||||
if ($expected[$index] -ne $actual[$index]) {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
$manifest = Get-Content -Raw $manifestPath | ConvertFrom-Json
|
||||
|
||||
Ensure-Directory (Split-Path $descriptorPath -Parent)
|
||||
foreach ($output in $manifest.generatedOutputs.PSObject.Properties.Value) {
|
||||
Ensure-Directory (Join-Path $repoRoot $output)
|
||||
}
|
||||
|
||||
$protoc = Resolve-Protoc
|
||||
$outputPath = $descriptorPath
|
||||
if ($Check) {
|
||||
$outputPath = Join-Path ([System.IO.Path]::GetTempPath()) ("mxaccessgw-client-v1-" + [System.Guid]::NewGuid().ToString("N") + ".protoset")
|
||||
}
|
||||
|
||||
try {
|
||||
& $protoc `
|
||||
"--proto_path=$protoRoot" `
|
||||
"--include_imports" `
|
||||
"--include_source_info" `
|
||||
"--descriptor_set_out=$outputPath" `
|
||||
"mxaccess_gateway.proto" `
|
||||
"mxaccess_worker.proto"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "protoc failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
if ($Check -and -not (Compare-FileBytes -ExpectedPath $descriptorPath -ActualPath $outputPath)) {
|
||||
throw "Client proto descriptor is stale. Run scripts/publish-client-proto-inputs.ps1 and commit the updated descriptor."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($Check -and (Test-Path $outputPath)) {
|
||||
Remove-Item -LiteralPath $outputPath
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -6,6 +6,8 @@ namespace MxGateway.Contracts;
|
||||
/// </summary>
|
||||
public static class GatewayContractInfo
|
||||
{
|
||||
public const uint GatewayProtocolVersion = 1;
|
||||
|
||||
public const uint WorkerProtocolVersion = 1;
|
||||
|
||||
public const string DefaultBackendName = "mxaccess-worker";
|
||||
|
||||
@@ -30,282 +30,282 @@ namespace MxGateway.Contracts.Proto {
|
||||
"ChFyZXF1ZXN0ZWRfYmFja2VuZBgBIAEoCRIbChNjbGllbnRfc2Vzc2lvbl9u",
|
||||
"YW1lGAIgASgJEh0KFWNsaWVudF9jb3JyZWxhdGlvbl9pZBgDIAEoCRIyCg9j",
|
||||
"b21tYW5kX3RpbWVvdXQYBCABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRp",
|
||||
"b24iiAIKEE9wZW5TZXNzaW9uUmVwbHkSEgoKc2Vzc2lvbl9pZBgBIAEoCRIU",
|
||||
"b24iqgIKEE9wZW5TZXNzaW9uUmVwbHkSEgoKc2Vzc2lvbl9pZBgBIAEoCRIU",
|
||||
"CgxiYWNrZW5kX25hbWUYAiABKAkSGQoRd29ya2VyX3Byb2Nlc3NfaWQYAyAB",
|
||||
"KAUSHwoXd29ya2VyX3Byb3RvY29sX3ZlcnNpb24YBCABKA0SFAoMY2FwYWJp",
|
||||
"bGl0aWVzGAUgAygJEjoKF2RlZmF1bHRfY29tbWFuZF90aW1lb3V0GAYgASgL",
|
||||
"MhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uEjwKD3Byb3RvY29sX3N0YXR1",
|
||||
"cxgHIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuUHJvdG9jb2xTdGF0dXMi",
|
||||
"SAoTQ2xvc2VTZXNzaW9uUmVxdWVzdBISCgpzZXNzaW9uX2lkGAEgASgJEh0K",
|
||||
"FWNsaWVudF9jb3JyZWxhdGlvbl9pZBgCIAEoCSKdAQoRQ2xvc2VTZXNzaW9u",
|
||||
"UmVwbHkSEgoKc2Vzc2lvbl9pZBgBIAEoCRI2CgtmaW5hbF9zdGF0ZRgCIAEo",
|
||||
"DjIhLm14YWNjZXNzX2dhdGV3YXkudjEuU2Vzc2lvblN0YXRlEjwKD3Byb3Rv",
|
||||
"Y29sX3N0YXR1cxgDIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuUHJvdG9j",
|
||||
"b2xTdGF0dXMiSAoTU3RyZWFtRXZlbnRzUmVxdWVzdBISCgpzZXNzaW9uX2lk",
|
||||
"GAEgASgJEh0KFWFmdGVyX3dvcmtlcl9zZXF1ZW5jZRgCIAEoBCJ2ChBNeENv",
|
||||
"bW1hbmRSZXF1ZXN0EhIKCnNlc3Npb25faWQYASABKAkSHQoVY2xpZW50X2Nv",
|
||||
"cnJlbGF0aW9uX2lkGAIgASgJEi8KB2NvbW1hbmQYAyABKAsyHi5teGFjY2Vz",
|
||||
"c19nYXRld2F5LnYxLk14Q29tbWFuZCKiDAoJTXhDb21tYW5kEjAKBGtpbmQY",
|
||||
"ASABKA4yIi5teGFjY2Vzc19nYXRld2F5LnYxLk14Q29tbWFuZEtpbmQSOAoI",
|
||||
"cmVnaXN0ZXIYCiABKAsyJC5teGFjY2Vzc19nYXRld2F5LnYxLlJlZ2lzdGVy",
|
||||
"Q29tbWFuZEgAEjwKCnVucmVnaXN0ZXIYCyABKAsyJi5teGFjY2Vzc19nYXRl",
|
||||
"d2F5LnYxLlVucmVnaXN0ZXJDb21tYW5kSAASNwoIYWRkX2l0ZW0YDCABKAsy",
|
||||
"Iy5teGFjY2Vzc19nYXRld2F5LnYxLkFkZEl0ZW1Db21tYW5kSAASOQoJYWRk",
|
||||
"X2l0ZW0yGA0gASgLMiQubXhhY2Nlc3NfZ2F0ZXdheS52MS5BZGRJdGVtMkNv",
|
||||
"bW1hbmRIABI9CgtyZW1vdmVfaXRlbRgOIAEoCzImLm14YWNjZXNzX2dhdGV3",
|
||||
"YXkudjEuUmVtb3ZlSXRlbUNvbW1hbmRIABI0CgZhZHZpc2UYDyABKAsyIi5t",
|
||||
"eGFjY2Vzc19nYXRld2F5LnYxLkFkdmlzZUNvbW1hbmRIABI5Cgl1bl9hZHZp",
|
||||
"c2UYECABKAsyJC5teGFjY2Vzc19nYXRld2F5LnYxLlVuQWR2aXNlQ29tbWFu",
|
||||
"ZEgAEksKEmFkdmlzZV9zdXBlcnZpc29yeRgRIAEoCzItLm14YWNjZXNzX2dh",
|
||||
"dGV3YXkudjEuQWR2aXNlU3VwZXJ2aXNvcnlDb21tYW5kSAASSAoRYWRkX2J1",
|
||||
"ZmZlcmVkX2l0ZW0YEiABKAsyKy5teGFjY2Vzc19nYXRld2F5LnYxLkFkZEJ1",
|
||||
"ZmZlcmVkSXRlbUNvbW1hbmRIABJdChxzZXRfYnVmZmVyZWRfdXBkYXRlX2lu",
|
||||
"dGVydmFsGBMgASgLMjUubXhhY2Nlc3NfZ2F0ZXdheS52MS5TZXRCdWZmZXJl",
|
||||
"ZFVwZGF0ZUludGVydmFsQ29tbWFuZEgAEjYKB3N1c3BlbmQYFCABKAsyIy5t",
|
||||
"eGFjY2Vzc19nYXRld2F5LnYxLlN1c3BlbmRDb21tYW5kSAASOAoIYWN0aXZh",
|
||||
"dGUYFSABKAsyJC5teGFjY2Vzc19nYXRld2F5LnYxLkFjdGl2YXRlQ29tbWFu",
|
||||
"ZEgAEjIKBXdyaXRlGBYgASgLMiEubXhhY2Nlc3NfZ2F0ZXdheS52MS5Xcml0",
|
||||
"ZUNvbW1hbmRIABI0CgZ3cml0ZTIYFyABKAsyIi5teGFjY2Vzc19nYXRld2F5",
|
||||
"LnYxLldyaXRlMkNvbW1hbmRIABJBCg13cml0ZV9zZWN1cmVkGBggASgLMigu",
|
||||
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5Xcml0ZVNlY3VyZWRDb21tYW5kSAASQwoO",
|
||||
"d3JpdGVfc2VjdXJlZDIYGSABKAsyKS5teGFjY2Vzc19nYXRld2F5LnYxLldy",
|
||||
"aXRlU2VjdXJlZDJDb21tYW5kSAASSQoRYXV0aGVudGljYXRlX3VzZXIYGiAB",
|
||||
"KAsyLC5teGFjY2Vzc19nYXRld2F5LnYxLkF1dGhlbnRpY2F0ZVVzZXJDb21t",
|
||||
"YW5kSAASTQoUYXJjaGVzdHJhX3VzZXJfdG9faWQYGyABKAsyLS5teGFjY2Vz",
|
||||
"c19nYXRld2F5LnYxLkFyY2hlc3RyQVVzZXJUb0lkQ29tbWFuZEgAEjAKBHBp",
|
||||
"bmcYZCABKAsyIC5teGFjY2Vzc19nYXRld2F5LnYxLlBpbmdDb21tYW5kSAAS",
|
||||
"SAoRZ2V0X3Nlc3Npb25fc3RhdGUYZSABKAsyKy5teGFjY2Vzc19nYXRld2F5",
|
||||
"LnYxLkdldFNlc3Npb25TdGF0ZUNvbW1hbmRIABJECg9nZXRfd29ya2VyX2lu",
|
||||
"Zm8YZiABKAsyKS5teGFjY2Vzc19nYXRld2F5LnYxLkdldFdvcmtlckluZm9D",
|
||||
"b21tYW5kSAASPwoMZHJhaW5fZXZlbnRzGGcgASgLMicubXhhY2Nlc3NfZ2F0",
|
||||
"ZXdheS52MS5EcmFpbkV2ZW50c0NvbW1hbmRIABJFCg9zaHV0ZG93bl93b3Jr",
|
||||
"ZXIYaCABKAsyKi5teGFjY2Vzc19nYXRld2F5LnYxLlNodXRkb3duV29ya2Vy",
|
||||
"Q29tbWFuZEgAQgkKB3BheWxvYWQiJgoPUmVnaXN0ZXJDb21tYW5kEhMKC2Ns",
|
||||
"aWVudF9uYW1lGAEgASgJIioKEVVucmVnaXN0ZXJDb21tYW5kEhUKDXNlcnZl",
|
||||
"cl9oYW5kbGUYASABKAUiQAoOQWRkSXRlbUNvbW1hbmQSFQoNc2VydmVyX2hh",
|
||||
"bmRsZRgBIAEoBRIXCg9pdGVtX2RlZmluaXRpb24YAiABKAkiVwoPQWRkSXRl",
|
||||
"bTJDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSFwoPaXRlbV9kZWZp",
|
||||
"bml0aW9uGAIgASgJEhQKDGl0ZW1fY29udGV4dBgDIAEoCSI/ChFSZW1vdmVJ",
|
||||
"dGVtQ29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgFEhMKC2l0ZW1faGFu",
|
||||
"ZGxlGAIgASgFIjsKDUFkdmlzZUNvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgB",
|
||||
"IAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBSI9Cg9VbkFkdmlzZUNvbW1hbmQS",
|
||||
"FQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBSJG",
|
||||
"ChhBZHZpc2VTdXBlcnZpc29yeUNvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgB",
|
||||
"IAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBSJeChZBZGRCdWZmZXJlZEl0ZW1D",
|
||||
"b21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSFwoPaXRlbV9kZWZpbml0",
|
||||
"aW9uGAIgASgJEhQKDGl0ZW1fY29udGV4dBgDIAEoCSJfCiBTZXRCdWZmZXJl",
|
||||
"ZFVwZGF0ZUludGVydmFsQ29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgF",
|
||||
"EiQKHHVwZGF0ZV9pbnRlcnZhbF9taWxsaXNlY29uZHMYAiABKAUiPAoOU3Vz",
|
||||
"cGVuZENvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hh",
|
||||
"bmRsZRgCIAEoBSI9Cg9BY3RpdmF0ZUNvbW1hbmQSFQoNc2VydmVyX2hhbmRs",
|
||||
"ZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBSJ4CgxXcml0ZUNvbW1hbmQS",
|
||||
"FQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBRIr",
|
||||
"CgV2YWx1ZRgDIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIP",
|
||||
"Cgd1c2VyX2lkGAQgASgFIrABCg1Xcml0ZTJDb21tYW5kEhUKDXNlcnZlcl9o",
|
||||
"cxgHIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuUHJvdG9jb2xTdGF0dXMS",
|
||||
"IAoYZ2F0ZXdheV9wcm90b2NvbF92ZXJzaW9uGAggASgNIkgKE0Nsb3NlU2Vz",
|
||||
"c2lvblJlcXVlc3QSEgoKc2Vzc2lvbl9pZBgBIAEoCRIdChVjbGllbnRfY29y",
|
||||
"cmVsYXRpb25faWQYAiABKAkinQEKEUNsb3NlU2Vzc2lvblJlcGx5EhIKCnNl",
|
||||
"c3Npb25faWQYASABKAkSNgoLZmluYWxfc3RhdGUYAiABKA4yIS5teGFjY2Vz",
|
||||
"c19nYXRld2F5LnYxLlNlc3Npb25TdGF0ZRI8Cg9wcm90b2NvbF9zdGF0dXMY",
|
||||
"AyABKAsyIy5teGFjY2Vzc19nYXRld2F5LnYxLlByb3RvY29sU3RhdHVzIkgK",
|
||||
"E1N0cmVhbUV2ZW50c1JlcXVlc3QSEgoKc2Vzc2lvbl9pZBgBIAEoCRIdChVh",
|
||||
"ZnRlcl93b3JrZXJfc2VxdWVuY2UYAiABKAQidgoQTXhDb21tYW5kUmVxdWVz",
|
||||
"dBISCgpzZXNzaW9uX2lkGAEgASgJEh0KFWNsaWVudF9jb3JyZWxhdGlvbl9p",
|
||||
"ZBgCIAEoCRIvCgdjb21tYW5kGAMgASgLMh4ubXhhY2Nlc3NfZ2F0ZXdheS52",
|
||||
"MS5NeENvbW1hbmQiogwKCU14Q29tbWFuZBIwCgRraW5kGAEgASgOMiIubXhh",
|
||||
"Y2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1hbmRLaW5kEjgKCHJlZ2lzdGVyGAog",
|
||||
"ASgLMiQubXhhY2Nlc3NfZ2F0ZXdheS52MS5SZWdpc3RlckNvbW1hbmRIABI8",
|
||||
"Cgp1bnJlZ2lzdGVyGAsgASgLMiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5VbnJl",
|
||||
"Z2lzdGVyQ29tbWFuZEgAEjcKCGFkZF9pdGVtGAwgASgLMiMubXhhY2Nlc3Nf",
|
||||
"Z2F0ZXdheS52MS5BZGRJdGVtQ29tbWFuZEgAEjkKCWFkZF9pdGVtMhgNIAEo",
|
||||
"CzIkLm14YWNjZXNzX2dhdGV3YXkudjEuQWRkSXRlbTJDb21tYW5kSAASPQoL",
|
||||
"cmVtb3ZlX2l0ZW0YDiABKAsyJi5teGFjY2Vzc19nYXRld2F5LnYxLlJlbW92",
|
||||
"ZUl0ZW1Db21tYW5kSAASNAoGYWR2aXNlGA8gASgLMiIubXhhY2Nlc3NfZ2F0",
|
||||
"ZXdheS52MS5BZHZpc2VDb21tYW5kSAASOQoJdW5fYWR2aXNlGBAgASgLMiQu",
|
||||
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5VbkFkdmlzZUNvbW1hbmRIABJLChJhZHZp",
|
||||
"c2Vfc3VwZXJ2aXNvcnkYESABKAsyLS5teGFjY2Vzc19nYXRld2F5LnYxLkFk",
|
||||
"dmlzZVN1cGVydmlzb3J5Q29tbWFuZEgAEkgKEWFkZF9idWZmZXJlZF9pdGVt",
|
||||
"GBIgASgLMisubXhhY2Nlc3NfZ2F0ZXdheS52MS5BZGRCdWZmZXJlZEl0ZW1D",
|
||||
"b21tYW5kSAASXQocc2V0X2J1ZmZlcmVkX3VwZGF0ZV9pbnRlcnZhbBgTIAEo",
|
||||
"CzI1Lm14YWNjZXNzX2dhdGV3YXkudjEuU2V0QnVmZmVyZWRVcGRhdGVJbnRl",
|
||||
"cnZhbENvbW1hbmRIABI2CgdzdXNwZW5kGBQgASgLMiMubXhhY2Nlc3NfZ2F0",
|
||||
"ZXdheS52MS5TdXNwZW5kQ29tbWFuZEgAEjgKCGFjdGl2YXRlGBUgASgLMiQu",
|
||||
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5BY3RpdmF0ZUNvbW1hbmRIABIyCgV3cml0",
|
||||
"ZRgWIAEoCzIhLm14YWNjZXNzX2dhdGV3YXkudjEuV3JpdGVDb21tYW5kSAAS",
|
||||
"NAoGd3JpdGUyGBcgASgLMiIubXhhY2Nlc3NfZ2F0ZXdheS52MS5Xcml0ZTJD",
|
||||
"b21tYW5kSAASQQoNd3JpdGVfc2VjdXJlZBgYIAEoCzIoLm14YWNjZXNzX2dh",
|
||||
"dGV3YXkudjEuV3JpdGVTZWN1cmVkQ29tbWFuZEgAEkMKDndyaXRlX3NlY3Vy",
|
||||
"ZWQyGBkgASgLMikubXhhY2Nlc3NfZ2F0ZXdheS52MS5Xcml0ZVNlY3VyZWQy",
|
||||
"Q29tbWFuZEgAEkkKEWF1dGhlbnRpY2F0ZV91c2VyGBogASgLMiwubXhhY2Nl",
|
||||
"c3NfZ2F0ZXdheS52MS5BdXRoZW50aWNhdGVVc2VyQ29tbWFuZEgAEk0KFGFy",
|
||||
"Y2hlc3RyYV91c2VyX3RvX2lkGBsgASgLMi0ubXhhY2Nlc3NfZ2F0ZXdheS52",
|
||||
"MS5BcmNoZXN0ckFVc2VyVG9JZENvbW1hbmRIABIwCgRwaW5nGGQgASgLMiAu",
|
||||
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5QaW5nQ29tbWFuZEgAEkgKEWdldF9zZXNz",
|
||||
"aW9uX3N0YXRlGGUgASgLMisubXhhY2Nlc3NfZ2F0ZXdheS52MS5HZXRTZXNz",
|
||||
"aW9uU3RhdGVDb21tYW5kSAASRAoPZ2V0X3dvcmtlcl9pbmZvGGYgASgLMiku",
|
||||
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5HZXRXb3JrZXJJbmZvQ29tbWFuZEgAEj8K",
|
||||
"DGRyYWluX2V2ZW50cxhnIAEoCzInLm14YWNjZXNzX2dhdGV3YXkudjEuRHJh",
|
||||
"aW5FdmVudHNDb21tYW5kSAASRQoPc2h1dGRvd25fd29ya2VyGGggASgLMiou",
|
||||
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5TaHV0ZG93bldvcmtlckNvbW1hbmRIAEIJ",
|
||||
"CgdwYXlsb2FkIiYKD1JlZ2lzdGVyQ29tbWFuZBITCgtjbGllbnRfbmFtZRgB",
|
||||
"IAEoCSIqChFVbnJlZ2lzdGVyQ29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEg",
|
||||
"ASgFIkAKDkFkZEl0ZW1Db21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUS",
|
||||
"FwoPaXRlbV9kZWZpbml0aW9uGAIgASgJIlcKD0FkZEl0ZW0yQ29tbWFuZBIV",
|
||||
"Cg1zZXJ2ZXJfaGFuZGxlGAEgASgFEhcKD2l0ZW1fZGVmaW5pdGlvbhgCIAEo",
|
||||
"CRIUCgxpdGVtX2NvbnRleHQYAyABKAkiPwoRUmVtb3ZlSXRlbUNvbW1hbmQS",
|
||||
"FQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBSI7",
|
||||
"Cg1BZHZpc2VDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLaXRl",
|
||||
"bV9oYW5kbGUYAiABKAUiPQoPVW5BZHZpc2VDb21tYW5kEhUKDXNlcnZlcl9o",
|
||||
"YW5kbGUYASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUiRgoYQWR2aXNlU3Vw",
|
||||
"ZXJ2aXNvcnlDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLaXRl",
|
||||
"bV9oYW5kbGUYAiABKAUiXgoWQWRkQnVmZmVyZWRJdGVtQ29tbWFuZBIVCg1z",
|
||||
"ZXJ2ZXJfaGFuZGxlGAEgASgFEhcKD2l0ZW1fZGVmaW5pdGlvbhgCIAEoCRIU",
|
||||
"CgxpdGVtX2NvbnRleHQYAyABKAkiXwogU2V0QnVmZmVyZWRVcGRhdGVJbnRl",
|
||||
"cnZhbENvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRIkChx1cGRhdGVf",
|
||||
"aW50ZXJ2YWxfbWlsbGlzZWNvbmRzGAIgASgFIjwKDlN1c3BlbmRDb21tYW5k",
|
||||
"EhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUi",
|
||||
"PQoPQWN0aXZhdGVDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoL",
|
||||
"aXRlbV9oYW5kbGUYAiABKAUieAoMV3JpdGVDb21tYW5kEhUKDXNlcnZlcl9o",
|
||||
"YW5kbGUYASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUSKwoFdmFsdWUYAyAB",
|
||||
"KAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSNQoPdGltZXN0YW1w",
|
||||
"X3ZhbHVlGAQgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlEg8K",
|
||||
"B3VzZXJfaWQYBSABKAUioQEKE1dyaXRlU2VjdXJlZENvbW1hbmQSFQoNc2Vy",
|
||||
"dmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBRIXCg9jdXJy",
|
||||
"ZW50X3VzZXJfaWQYAyABKAUSGAoQdmVyaWZpZXJfdXNlcl9pZBgEIAEoBRIr",
|
||||
"CgV2YWx1ZRgFIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZSLZ",
|
||||
"AQoUV3JpdGVTZWN1cmVkMkNvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEo",
|
||||
"BRITCgtpdGVtX2hhbmRsZRgCIAEoBRIXCg9jdXJyZW50X3VzZXJfaWQYAyAB",
|
||||
"KAUSGAoQdmVyaWZpZXJfdXNlcl9pZBgEIAEoBRIrCgV2YWx1ZRgFIAEoCzIc",
|
||||
"Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRI1Cg90aW1lc3RhbXBfdmFs",
|
||||
"dWUYBiABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUiYwoXQXV0",
|
||||
"aGVudGljYXRlVXNlckNvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRIT",
|
||||
"Cgt2ZXJpZnlfdXNlchgCIAEoCRIcChR2ZXJpZnlfdXNlcl9wYXNzd29yZBgD",
|
||||
"IAEoCSJHChhBcmNoZXN0ckFVc2VyVG9JZENvbW1hbmQSFQoNc2VydmVyX2hh",
|
||||
"bmRsZRgBIAEoBRIUCgx1c2VyX2lkX2d1aWQYAiABKAkiHgoLUGluZ0NvbW1h",
|
||||
"bmQSDwoHbWVzc2FnZRgBIAEoCSIYChZHZXRTZXNzaW9uU3RhdGVDb21tYW5k",
|
||||
"IhYKFEdldFdvcmtlckluZm9Db21tYW5kIigKEkRyYWluRXZlbnRzQ29tbWFu",
|
||||
"ZBISCgptYXhfZXZlbnRzGAEgASgNIkgKFVNodXRkb3duV29ya2VyQ29tbWFu",
|
||||
"ZBIvCgxncmFjZV9wZXJpb2QYASABKAsyGS5nb29nbGUucHJvdG9idWYuRHVy",
|
||||
"YXRpb24ikAgKDk14Q29tbWFuZFJlcGx5EhIKCnNlc3Npb25faWQYASABKAkS",
|
||||
"FgoOY29ycmVsYXRpb25faWQYAiABKAkSMAoEa2luZBgDIAEoDjIiLm14YWNj",
|
||||
"ZXNzX2dhdGV3YXkudjEuTXhDb21tYW5kS2luZBI8Cg9wcm90b2NvbF9zdGF0",
|
||||
"dXMYBCABKAsyIy5teGFjY2Vzc19nYXRld2F5LnYxLlByb3RvY29sU3RhdHVz",
|
||||
"EhQKB2hyZXN1bHQYBSABKAVIAYgBARIyCgxyZXR1cm5fdmFsdWUYBiABKAsy",
|
||||
"HC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSNAoIc3RhdHVzZXMYByAD",
|
||||
"KAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLk14U3RhdHVzUHJveHkSGgoSZGlh",
|
||||
"Z25vc3RpY19tZXNzYWdlGAggASgJEjYKCHJlZ2lzdGVyGBQgASgLMiIubXhh",
|
||||
"Y2Nlc3NfZ2F0ZXdheS52MS5SZWdpc3RlclJlcGx5SAASNQoIYWRkX2l0ZW0Y",
|
||||
"FSABKAsyIS5teGFjY2Vzc19nYXRld2F5LnYxLkFkZEl0ZW1SZXBseUgAEjcK",
|
||||
"CWFkZF9pdGVtMhgWIAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuQWRkSXRl",
|
||||
"bTJSZXBseUgAEkYKEWFkZF9idWZmZXJlZF9pdGVtGBcgASgLMikubXhhY2Nl",
|
||||
"c3NfZ2F0ZXdheS52MS5BZGRCdWZmZXJlZEl0ZW1SZXBseUgAEjQKB3N1c3Bl",
|
||||
"bmQYGCABKAsyIS5teGFjY2Vzc19nYXRld2F5LnYxLlN1c3BlbmRSZXBseUgA",
|
||||
"EjYKCGFjdGl2YXRlGBkgASgLMiIubXhhY2Nlc3NfZ2F0ZXdheS52MS5BY3Rp",
|
||||
"dmF0ZVJlcGx5SAASRwoRYXV0aGVudGljYXRlX3VzZXIYGiABKAsyKi5teGFj",
|
||||
"Y2Vzc19nYXRld2F5LnYxLkF1dGhlbnRpY2F0ZVVzZXJSZXBseUgAEksKFGFy",
|
||||
"Y2hlc3RyYV91c2VyX3RvX2lkGBsgASgLMisubXhhY2Nlc3NfZ2F0ZXdheS52",
|
||||
"MS5BcmNoZXN0ckFVc2VyVG9JZFJlcGx5SAASPwoNc2Vzc2lvbl9zdGF0ZRhk",
|
||||
"IAEoCzImLm14YWNjZXNzX2dhdGV3YXkudjEuU2Vzc2lvblN0YXRlUmVwbHlI",
|
||||
"ABI7Cgt3b3JrZXJfaW5mbxhlIAEoCzIkLm14YWNjZXNzX2dhdGV3YXkudjEu",
|
||||
"V29ya2VySW5mb1JlcGx5SAASPQoMZHJhaW5fZXZlbnRzGGYgASgLMiUubXhh",
|
||||
"Y2Nlc3NfZ2F0ZXdheS52MS5EcmFpbkV2ZW50c1JlcGx5SABCCQoHcGF5bG9h",
|
||||
"ZEIKCghfaHJlc3VsdCImCg1SZWdpc3RlclJlcGx5EhUKDXNlcnZlcl9oYW5k",
|
||||
"bGUYASABKAUiIwoMQWRkSXRlbVJlcGx5EhMKC2l0ZW1faGFuZGxlGAEgASgF",
|
||||
"IiQKDUFkZEl0ZW0yUmVwbHkSEwoLaXRlbV9oYW5kbGUYASABKAUiKwoUQWRk",
|
||||
"QnVmZmVyZWRJdGVtUmVwbHkSEwoLaXRlbV9oYW5kbGUYASABKAUiQgoMU3Vz",
|
||||
"cGVuZFJlcGx5EjIKBnN0YXR1cxgBIAEoCzIiLm14YWNjZXNzX2dhdGV3YXku",
|
||||
"djEuTXhTdGF0dXNQcm94eSJDCg1BY3RpdmF0ZVJlcGx5EjIKBnN0YXR1cxgB",
|
||||
"IAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNQcm94eSIoChVB",
|
||||
"dXRoZW50aWNhdGVVc2VyUmVwbHkSDwoHdXNlcl9pZBgBIAEoBSIpChZBcmNo",
|
||||
"ZXN0ckFVc2VyVG9JZFJlcGx5Eg8KB3VzZXJfaWQYASABKAUiRQoRU2Vzc2lv",
|
||||
"blN0YXRlUmVwbHkSMAoFc3RhdGUYASABKA4yIS5teGFjY2Vzc19nYXRld2F5",
|
||||
"LnYxLlNlc3Npb25TdGF0ZSJ1Cg9Xb3JrZXJJbmZvUmVwbHkSGQoRd29ya2Vy",
|
||||
"X3Byb2Nlc3NfaWQYASABKAUSFgoOd29ya2VyX3ZlcnNpb24YAiABKAkSFwoP",
|
||||
"bXhhY2Nlc3NfcHJvZ2lkGAMgASgJEhYKDm14YWNjZXNzX2Nsc2lkGAQgASgJ",
|
||||
"IkAKEERyYWluRXZlbnRzUmVwbHkSLAoGZXZlbnRzGAEgAygLMhwubXhhY2Nl",
|
||||
"c3NfZ2F0ZXdheS52MS5NeEV2ZW50IpsGCgdNeEV2ZW50EjIKBmZhbWlseRgB",
|
||||
"IAEoDjIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhFdmVudEZhbWlseRISCgpz",
|
||||
"ZXNzaW9uX2lkGAIgASgJEhUKDXNlcnZlcl9oYW5kbGUYAyABKAUSEwoLaXRl",
|
||||
"bV9oYW5kbGUYBCABKAUSKwoFdmFsdWUYBSABKAsyHC5teGFjY2Vzc19nYXRl",
|
||||
"d2F5LnYxLk14VmFsdWUSDwoHcXVhbGl0eRgGIAEoBRI0ChBzb3VyY2VfdGlt",
|
||||
"ZXN0YW1wGAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBI0Cghz",
|
||||
"dGF0dXNlcxgIIAMoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNQ",
|
||||
"cm94eRIXCg93b3JrZXJfc2VxdWVuY2UYCSABKAQSNAoQd29ya2VyX3RpbWVz",
|
||||
"dGFtcBgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASPQoZZ2F0",
|
||||
"ZXdheV9yZWNlaXZlX3RpbWVzdGFtcBgLIAEoCzIaLmdvb2dsZS5wcm90b2J1",
|
||||
"Zi5UaW1lc3RhbXASFAoHaHJlc3VsdBgMIAEoBUgBiAEBEhIKCnJhd19zdGF0",
|
||||
"dXMYDSABKAkSQAoOb25fZGF0YV9jaGFuZ2UYFCABKAsyJi5teGFjY2Vzc19n",
|
||||
"YXRld2F5LnYxLk9uRGF0YUNoYW5nZUV2ZW50SAASRgoRb25fd3JpdGVfY29t",
|
||||
"cGxldGUYFSABKAsyKS5teGFjY2Vzc19nYXRld2F5LnYxLk9uV3JpdGVDb21w",
|
||||
"bGV0ZUV2ZW50SAASSQoSb3BlcmF0aW9uX2NvbXBsZXRlGBYgASgLMisubXhh",
|
||||
"Y2Nlc3NfZ2F0ZXdheS52MS5PcGVyYXRpb25Db21wbGV0ZUV2ZW50SAASUQoX",
|
||||
"b25fYnVmZmVyZWRfZGF0YV9jaGFuZ2UYFyABKAsyLi5teGFjY2Vzc19nYXRl",
|
||||
"d2F5LnYxLk9uQnVmZmVyZWREYXRhQ2hhbmdlRXZlbnRIAEIGCgRib2R5QgoK",
|
||||
"CF9ocmVzdWx0IhMKEU9uRGF0YUNoYW5nZUV2ZW50IhYKFE9uV3JpdGVDb21w",
|
||||
"bGV0ZUV2ZW50IhgKFk9wZXJhdGlvbkNvbXBsZXRlRXZlbnQi1AEKGU9uQnVm",
|
||||
"ZmVyZWREYXRhQ2hhbmdlRXZlbnQSMgoJZGF0YV90eXBlGAEgASgOMh8ubXhh",
|
||||
"Y2Nlc3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEjQKDnF1YWxpdHlfdmFsdWVz",
|
||||
"GAIgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeEFycmF5EjYKEHRpbWVz",
|
||||
"dGFtcF92YWx1ZXMYAyABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14QXJy",
|
||||
"YXkSFQoNcmF3X2RhdGFfdHlwZRgEIAEoBSLrAQoNTXhTdGF0dXNQcm94eRIP",
|
||||
"CgdzdWNjZXNzGAEgASgFEjcKCGNhdGVnb3J5GAIgASgOMiUubXhhY2Nlc3Nf",
|
||||
"Z2F0ZXdheS52MS5NeFN0YXR1c0NhdGVnb3J5EjgKC2RldGVjdGVkX2J5GAMg",
|
||||
"ASgOMiMubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1c1NvdXJjZRIOCgZk",
|
||||
"ZXRhaWwYBCABKAUSFAoMcmF3X2NhdGVnb3J5GAUgASgFEhcKD3Jhd19kZXRl",
|
||||
"Y3RlZF9ieRgGIAEoBRIXCg9kaWFnbm9zdGljX3RleHQYByABKAkipwMKB014",
|
||||
"VmFsdWUSMgoJZGF0YV90eXBlGAEgASgOMh8ubXhhY2Nlc3NfZ2F0ZXdheS52",
|
||||
"MS5NeERhdGFUeXBlEhQKDHZhcmlhbnRfdHlwZRgCIAEoCRIPCgdpc19udWxs",
|
||||
"GAMgASgIEhYKDnJhd19kaWFnbm9zdGljGAQgASgJEhUKDXJhd19kYXRhX3R5",
|
||||
"cGUYBSABKAUSFAoKYm9vbF92YWx1ZRgKIAEoCEgAEhUKC2ludDMyX3ZhbHVl",
|
||||
"GAsgASgFSAASFQoLaW50NjRfdmFsdWUYDCABKANIABIVCgtmbG9hdF92YWx1",
|
||||
"ZRgNIAEoAkgAEhYKDGRvdWJsZV92YWx1ZRgOIAEoAUgAEhYKDHN0cmluZ192",
|
||||
"YWx1ZRgPIAEoCUgAEjUKD3RpbWVzdGFtcF92YWx1ZRgQIAEoCzIaLmdvb2ds",
|
||||
"ZS5wcm90b2J1Zi5UaW1lc3RhbXBIABIzCgthcnJheV92YWx1ZRgRIAEoCzIc",
|
||||
"Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhBcnJheUgAEhMKCXJhd192YWx1ZRgS",
|
||||
"IAEoDEgAQgYKBGtpbmQi/gQKB014QXJyYXkSOgoRZWxlbWVudF9kYXRhX3R5",
|
||||
"cGUYASABKA4yHy5teGFjY2Vzc19nYXRld2F5LnYxLk14RGF0YVR5cGUSFAoM",
|
||||
"dmFyaWFudF90eXBlGAIgASgJEhIKCmRpbWVuc2lvbnMYAyADKA0SFgoOcmF3",
|
||||
"X2RpYWdub3N0aWMYBCABKAkSHQoVcmF3X2VsZW1lbnRfZGF0YV90eXBlGAUg",
|
||||
"ASgFEjUKC2Jvb2xfdmFsdWVzGAogASgLMh4ubXhhY2Nlc3NfZ2F0ZXdheS52",
|
||||
"MS5Cb29sQXJyYXlIABI3CgxpbnQzMl92YWx1ZXMYCyABKAsyHy5teGFjY2Vz",
|
||||
"c19nYXRld2F5LnYxLkludDMyQXJyYXlIABI3CgxpbnQ2NF92YWx1ZXMYDCAB",
|
||||
"KAsyHy5teGFjY2Vzc19nYXRld2F5LnYxLkludDY0QXJyYXlIABI3CgxmbG9h",
|
||||
"dF92YWx1ZXMYDSABKAsyHy5teGFjY2Vzc19nYXRld2F5LnYxLkZsb2F0QXJy",
|
||||
"YXlIABI5Cg1kb3VibGVfdmFsdWVzGA4gASgLMiAubXhhY2Nlc3NfZ2F0ZXdh",
|
||||
"eS52MS5Eb3VibGVBcnJheUgAEjkKDXN0cmluZ192YWx1ZXMYDyABKAsyIC5t",
|
||||
"eGFjY2Vzc19nYXRld2F5LnYxLlN0cmluZ0FycmF5SAASPwoQdGltZXN0YW1w",
|
||||
"X3ZhbHVlcxgQIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuVGltZXN0YW1w",
|
||||
"QXJyYXlIABIzCgpyYXdfdmFsdWVzGBEgASgLMh0ubXhhY2Nlc3NfZ2F0ZXdh",
|
||||
"eS52MS5SYXdBcnJheUgAQggKBnZhbHVlcyIbCglCb29sQXJyYXkSDgoGdmFs",
|
||||
"dWVzGAEgAygIIhwKCkludDMyQXJyYXkSDgoGdmFsdWVzGAEgAygFIhwKCklu",
|
||||
"dDY0QXJyYXkSDgoGdmFsdWVzGAEgAygDIhwKCkZsb2F0QXJyYXkSDgoGdmFs",
|
||||
"dWVzGAEgAygCIh0KC0RvdWJsZUFycmF5Eg4KBnZhbHVlcxgBIAMoASIdCgtT",
|
||||
"dHJpbmdBcnJheRIOCgZ2YWx1ZXMYASADKAkiPAoOVGltZXN0YW1wQXJyYXkS",
|
||||
"KgoGdmFsdWVzGAEgAygLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCIa",
|
||||
"CghSYXdBcnJheRIOCgZ2YWx1ZXMYASADKAwiWAoOUHJvdG9jb2xTdGF0dXMS",
|
||||
"NQoEY29kZRgBIAEoDjInLm14YWNjZXNzX2dhdGV3YXkudjEuUHJvdG9jb2xT",
|
||||
"dGF0dXNDb2RlEg8KB21lc3NhZ2UYAiABKAkqvwYKDU14Q29tbWFuZEtpbmQS",
|
||||
"HwobTVhfQ09NTUFORF9LSU5EX1VOU1BFQ0lGSUVEEAASHAoYTVhfQ09NTUFO",
|
||||
"RF9LSU5EX1JFR0lTVEVSEAESHgoaTVhfQ09NTUFORF9LSU5EX1VOUkVHSVNU",
|
||||
"RVIQAhIcChhNWF9DT01NQU5EX0tJTkRfQUREX0lURU0QAxIdChlNWF9DT01N",
|
||||
"QU5EX0tJTkRfQUREX0lURU0yEAQSHwobTVhfQ09NTUFORF9LSU5EX1JFTU9W",
|
||||
"RV9JVEVNEAUSGgoWTVhfQ09NTUFORF9LSU5EX0FEVklTRRAGEh0KGU1YX0NP",
|
||||
"TU1BTkRfS0lORF9VTl9BRFZJU0UQBxImCiJNWF9DT01NQU5EX0tJTkRfQURW",
|
||||
"SVNFX1NVUEVSVklTT1JZEAgSJQohTVhfQ09NTUFORF9LSU5EX0FERF9CVUZG",
|
||||
"RVJFRF9JVEVNEAkSMAosTVhfQ09NTUFORF9LSU5EX1NFVF9CVUZGRVJFRF9V",
|
||||
"UERBVEVfSU5URVJWQUwQChIbChdNWF9DT01NQU5EX0tJTkRfU1VTUEVORBAL",
|
||||
"EhwKGE1YX0NPTU1BTkRfS0lORF9BQ1RJVkFURRAMEhkKFU1YX0NPTU1BTkRf",
|
||||
"S0lORF9XUklURRANEhoKFk1YX0NPTU1BTkRfS0lORF9XUklURTIQDhIhCh1N",
|
||||
"WF9DT01NQU5EX0tJTkRfV1JJVEVfU0VDVVJFRBAPEiIKHk1YX0NPTU1BTkRf",
|
||||
"S0lORF9XUklURV9TRUNVUkVEMhAQEiUKIU1YX0NPTU1BTkRfS0lORF9BVVRI",
|
||||
"RU5USUNBVEVfVVNFUhAREigKJE1YX0NPTU1BTkRfS0lORF9BUkNIRVNUUkFf",
|
||||
"VVNFUl9UT19JRBASEhgKFE1YX0NPTU1BTkRfS0lORF9QSU5HEGQSJQohTVhf",
|
||||
"Q09NTUFORF9LSU5EX0dFVF9TRVNTSU9OX1NUQVRFEGUSIwofTVhfQ09NTUFO",
|
||||
"RF9LSU5EX0dFVF9XT1JLRVJfSU5GTxBmEiAKHE1YX0NPTU1BTkRfS0lORF9E",
|
||||
"UkFJTl9FVkVOVFMQZxIjCh9NWF9DT01NQU5EX0tJTkRfU0hVVERPV05fV09S",
|
||||
"S0VSEGgq0AEKDU14RXZlbnRGYW1pbHkSHwobTVhfRVZFTlRfRkFNSUxZX1VO",
|
||||
"U1BFQ0lGSUVEEAASIgoeTVhfRVZFTlRfRkFNSUxZX09OX0RBVEFfQ0hBTkdF",
|
||||
"EAESJQohTVhfRVZFTlRfRkFNSUxZX09OX1dSSVRFX0NPTVBMRVRFEAISJgoi",
|
||||
"TVhfRVZFTlRfRkFNSUxZX09QRVJBVElPTl9DT01QTEVURRADEisKJ01YX0VW",
|
||||
"RU5UX0ZBTUlMWV9PTl9CVUZGRVJFRF9EQVRBX0NIQU5HRRAEKqUDChBNeFN0",
|
||||
"YXR1c0NhdGVnb3J5EiIKHk1YX1NUQVRVU19DQVRFR09SWV9VTlNQRUNJRklF",
|
||||
"RBAAEh4KGk1YX1NUQVRVU19DQVRFR09SWV9VTktOT1dOEAESGQoVTVhfU1RB",
|
||||
"VFVTX0NBVEVHT1JZX09LEAISHgoaTVhfU1RBVFVTX0NBVEVHT1JZX1BFTkRJ",
|
||||
"TkcQAxIeChpNWF9TVEFUVVNfQ0FURUdPUllfV0FSTklORxAEEioKJk1YX1NU",
|
||||
"QVRVU19DQVRFR09SWV9DT01NVU5JQ0FUSU9OX0VSUk9SEAUSKgomTVhfU1RB",
|
||||
"VFVTX0NBVEVHT1JZX0NPTkZJR1VSQVRJT05fRVJST1IQBhIoCiRNWF9TVEFU",
|
||||
"VVNfQ0FURUdPUllfT1BFUkFUSU9OQUxfRVJST1IQBxIlCiFNWF9TVEFUVVNf",
|
||||
"Q0FURUdPUllfU0VDVVJJVFlfRVJST1IQCBIlCiFNWF9TVEFUVVNfQ0FURUdP",
|
||||
"UllfU09GVFdBUkVfRVJST1IQCRIiCh5NWF9TVEFUVVNfQ0FURUdPUllfT1RI",
|
||||
"RVJfRVJST1IQCirKAgoOTXhTdGF0dXNTb3VyY2USIAocTVhfU1RBVFVTX1NP",
|
||||
"VVJDRV9VTlNQRUNJRklFRBAAEhwKGE1YX1NUQVRVU19TT1VSQ0VfVU5LTk9X",
|
||||
"ThABEiMKH01YX1NUQVRVU19TT1VSQ0VfUkVRVUVTVElOR19MTVgQAhIjCh9N",
|
||||
"WF9TVEFUVVNfU09VUkNFX1JFU1BPTkRJTkdfTE1YEAMSIwofTVhfU1RBVFVT",
|
||||
"X1NPVVJDRV9SRVFVRVNUSU5HX05NWBAEEiMKH01YX1NUQVRVU19TT1VSQ0Vf",
|
||||
"UkVTUE9ORElOR19OTVgQBRIxCi1NWF9TVEFUVVNfU09VUkNFX1JFUVVFU1RJ",
|
||||
"TkdfQVVUT01BVElPTl9PQkpFQ1QQBhIxCi1NWF9TVEFUVVNfU09VUkNFX1JF",
|
||||
"U1BPTkRJTkdfQVVUT01BVElPTl9PQkpFQ1QQByrdBAoKTXhEYXRhVHlwZRIc",
|
||||
"ChhNWF9EQVRBX1RZUEVfVU5TUEVDSUZJRUQQABIYChRNWF9EQVRBX1RZUEVf",
|
||||
"VU5LTk9XThABEhgKFE1YX0RBVEFfVFlQRV9OT19EQVRBEAISGAoUTVhfREFU",
|
||||
"QV9UWVBFX0JPT0xFQU4QAxIYChRNWF9EQVRBX1RZUEVfSU5URUdFUhAEEhYK",
|
||||
"Ek1YX0RBVEFfVFlQRV9GTE9BVBAFEhcKE01YX0RBVEFfVFlQRV9ET1VCTEUQ",
|
||||
"BhIXChNNWF9EQVRBX1RZUEVfU1RSSU5HEAcSFQoRTVhfREFUQV9UWVBFX1RJ",
|
||||
"TUUQCBIdChlNWF9EQVRBX1RZUEVfRUxBUFNFRF9USU1FEAkSHwobTVhfREFU",
|
||||
"QV9UWVBFX1JFRkVSRU5DRV9UWVBFEAoSHAoYTVhfREFUQV9UWVBFX1NUQVRV",
|
||||
"U19UWVBFEAsSFQoRTVhfREFUQV9UWVBFX0VOVU0QDBItCilNWF9EQVRBX1RZ",
|
||||
"UEVfU0VDVVJJVFlfQ0xBU1NJRklDQVRJT05fRU5VTRANEiIKHk1YX0RBVEFf",
|
||||
"VFlQRV9EQVRBX1FVQUxJVFlfVFlQRRAOEh8KG01YX0RBVEFfVFlQRV9RVUFM",
|
||||
"SUZJRURfRU5VTRAPEiEKHU1YX0RBVEFfVFlQRV9RVUFMSUZJRURfU1RSVUNU",
|
||||
"EBASKQolTVhfREFUQV9UWVBFX0lOVEVSTkFUSU9OQUxJWkVEX1NUUklORxAR",
|
||||
"EhsKF01YX0RBVEFfVFlQRV9CSUdfU1RSSU5HEBISFAoQTVhfREFUQV9UWVBF",
|
||||
"X0VORBATKqMDChJQcm90b2NvbFN0YXR1c0NvZGUSJAogUFJPVE9DT0xfU1RB",
|
||||
"VFVTX0NPREVfVU5TUEVDSUZJRUQQABIbChdQUk9UT0NPTF9TVEFUVVNfQ09E",
|
||||
"RV9PSxABEigKJFBST1RPQ09MX1NUQVRVU19DT0RFX0lOVkFMSURfUkVRVUVT",
|
||||
"VBACEioKJlBST1RPQ09MX1NUQVRVU19DT0RFX1NFU1NJT05fTk9UX0ZPVU5E",
|
||||
"EAMSKgomUFJPVE9DT0xfU1RBVFVTX0NPREVfU0VTU0lPTl9OT1RfUkVBRFkQ",
|
||||
"BBIrCidQUk9UT0NPTF9TVEFUVVNfQ09ERV9XT1JLRVJfVU5BVkFJTEFCTEUQ",
|
||||
"BRIgChxQUk9UT0NPTF9TVEFUVVNfQ09ERV9USU1FT1VUEAYSIQodUFJPVE9D",
|
||||
"T0xfU1RBVFVTX0NPREVfQ0FOQ0VMRUQQBxIrCidQUk9UT0NPTF9TVEFUVVNf",
|
||||
"Q09ERV9QUk9UT0NPTF9WSU9MQVRJT04QCBIpCiVQUk9UT0NPTF9TVEFUVVNf",
|
||||
"Q09ERV9NWEFDQ0VTU19GQUlMVVJFEAkqvwIKDFNlc3Npb25TdGF0ZRIdChlT",
|
||||
"RVNTSU9OX1NUQVRFX1VOU1BFQ0lGSUVEEAASGgoWU0VTU0lPTl9TVEFURV9D",
|
||||
"UkVBVElORxABEiEKHVNFU1NJT05fU1RBVEVfU1RBUlRJTkdfV09SS0VSEAIS",
|
||||
"IgoeU0VTU0lPTl9TVEFURV9XQUlUSU5HX0ZPUl9QSVBFEAMSHQoZU0VTU0lP",
|
||||
"Tl9TVEFURV9IQU5EU0hBS0lORxAEEiUKIVNFU1NJT05fU1RBVEVfSU5JVElB",
|
||||
"TElaSU5HX1dPUktFUhAFEhcKE1NFU1NJT05fU1RBVEVfUkVBRFkQBhIZChVT",
|
||||
"RVNTSU9OX1NUQVRFX0NMT1NJTkcQBxIYChRTRVNTSU9OX1NUQVRFX0NMT1NF",
|
||||
"RBAIEhkKFVNFU1NJT05fU1RBVEVfRkFVTFRFRBAJMoIDCg9NeEFjY2Vzc0dh",
|
||||
"dGV3YXkSXQoLT3BlblNlc3Npb24SJy5teGFjY2Vzc19nYXRld2F5LnYxLk9w",
|
||||
"ZW5TZXNzaW9uUmVxdWVzdBolLm14YWNjZXNzX2dhdGV3YXkudjEuT3BlblNl",
|
||||
"c3Npb25SZXBseRJgCgxDbG9zZVNlc3Npb24SKC5teGFjY2Vzc19nYXRld2F5",
|
||||
"LnYxLkNsb3NlU2Vzc2lvblJlcXVlc3QaJi5teGFjY2Vzc19nYXRld2F5LnYx",
|
||||
"LkNsb3NlU2Vzc2lvblJlcGx5ElQKBkludm9rZRIlLm14YWNjZXNzX2dhdGV3",
|
||||
"YXkudjEuTXhDb21tYW5kUmVxdWVzdBojLm14YWNjZXNzX2dhdGV3YXkudjEu",
|
||||
"TXhDb21tYW5kUmVwbHkSWAoMU3RyZWFtRXZlbnRzEigubXhhY2Nlc3NfZ2F0",
|
||||
"ZXdheS52MS5TdHJlYW1FdmVudHNSZXF1ZXN0GhwubXhhY2Nlc3NfZ2F0ZXdh",
|
||||
"eS52MS5NeEV2ZW50MAFCHKoCGU14R2F0ZXdheS5Db250cmFjdHMuUHJvdG9i",
|
||||
"BnByb3RvMw=="));
|
||||
"KAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSDwoHdXNlcl9pZBgE",
|
||||
"IAEoBSKwAQoNV3JpdGUyQ29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgF",
|
||||
"EhMKC2l0ZW1faGFuZGxlGAIgASgFEisKBXZhbHVlGAMgASgLMhwubXhhY2Nl",
|
||||
"c3NfZ2F0ZXdheS52MS5NeFZhbHVlEjUKD3RpbWVzdGFtcF92YWx1ZRgEIAEo",
|
||||
"CzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIPCgd1c2VyX2lkGAUg",
|
||||
"ASgFIqEBChNXcml0ZVNlY3VyZWRDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUY",
|
||||
"ASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUSFwoPY3VycmVudF91c2VyX2lk",
|
||||
"GAMgASgFEhgKEHZlcmlmaWVyX3VzZXJfaWQYBCABKAUSKwoFdmFsdWUYBSAB",
|
||||
"KAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUi2QEKFFdyaXRlU2Vj",
|
||||
"dXJlZDJDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLaXRlbV9o",
|
||||
"YW5kbGUYAiABKAUSFwoPY3VycmVudF91c2VyX2lkGAMgASgFEhgKEHZlcmlm",
|
||||
"aWVyX3VzZXJfaWQYBCABKAUSKwoFdmFsdWUYBSABKAsyHC5teGFjY2Vzc19n",
|
||||
"YXRld2F5LnYxLk14VmFsdWUSNQoPdGltZXN0YW1wX3ZhbHVlGAYgASgLMhwu",
|
||||
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlImMKF0F1dGhlbnRpY2F0ZVVz",
|
||||
"ZXJDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLdmVyaWZ5X3Vz",
|
||||
"ZXIYAiABKAkSHAoUdmVyaWZ5X3VzZXJfcGFzc3dvcmQYAyABKAkiRwoYQXJj",
|
||||
"aGVzdHJBVXNlclRvSWRDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUS",
|
||||
"FAoMdXNlcl9pZF9ndWlkGAIgASgJIh4KC1BpbmdDb21tYW5kEg8KB21lc3Nh",
|
||||
"Z2UYASABKAkiGAoWR2V0U2Vzc2lvblN0YXRlQ29tbWFuZCIWChRHZXRXb3Jr",
|
||||
"ZXJJbmZvQ29tbWFuZCIoChJEcmFpbkV2ZW50c0NvbW1hbmQSEgoKbWF4X2V2",
|
||||
"ZW50cxgBIAEoDSJIChVTaHV0ZG93bldvcmtlckNvbW1hbmQSLwoMZ3JhY2Vf",
|
||||
"cGVyaW9kGAEgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uIpAICg5N",
|
||||
"eENvbW1hbmRSZXBseRISCgpzZXNzaW9uX2lkGAEgASgJEhYKDmNvcnJlbGF0",
|
||||
"aW9uX2lkGAIgASgJEjAKBGtpbmQYAyABKA4yIi5teGFjY2Vzc19nYXRld2F5",
|
||||
"LnYxLk14Q29tbWFuZEtpbmQSPAoPcHJvdG9jb2xfc3RhdHVzGAQgASgLMiMu",
|
||||
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5Qcm90b2NvbFN0YXR1cxIUCgdocmVzdWx0",
|
||||
"GAUgASgFSAGIAQESMgoMcmV0dXJuX3ZhbHVlGAYgASgLMhwubXhhY2Nlc3Nf",
|
||||
"Z2F0ZXdheS52MS5NeFZhbHVlEjQKCHN0YXR1c2VzGAcgAygLMiIubXhhY2Nl",
|
||||
"c3NfZ2F0ZXdheS52MS5NeFN0YXR1c1Byb3h5EhoKEmRpYWdub3N0aWNfbWVz",
|
||||
"c2FnZRgIIAEoCRI2CghyZWdpc3RlchgUIAEoCzIiLm14YWNjZXNzX2dhdGV3",
|
||||
"YXkudjEuUmVnaXN0ZXJSZXBseUgAEjUKCGFkZF9pdGVtGBUgASgLMiEubXhh",
|
||||
"Y2Nlc3NfZ2F0ZXdheS52MS5BZGRJdGVtUmVwbHlIABI3CglhZGRfaXRlbTIY",
|
||||
"FiABKAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLkFkZEl0ZW0yUmVwbHlIABJG",
|
||||
"ChFhZGRfYnVmZmVyZWRfaXRlbRgXIAEoCzIpLm14YWNjZXNzX2dhdGV3YXku",
|
||||
"djEuQWRkQnVmZmVyZWRJdGVtUmVwbHlIABI0CgdzdXNwZW5kGBggASgLMiEu",
|
||||
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5TdXNwZW5kUmVwbHlIABI2CghhY3RpdmF0",
|
||||
"ZRgZIAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuQWN0aXZhdGVSZXBseUgA",
|
||||
"EkcKEWF1dGhlbnRpY2F0ZV91c2VyGBogASgLMioubXhhY2Nlc3NfZ2F0ZXdh",
|
||||
"eS52MS5BdXRoZW50aWNhdGVVc2VyUmVwbHlIABJLChRhcmNoZXN0cmFfdXNl",
|
||||
"cl90b19pZBgbIAEoCzIrLm14YWNjZXNzX2dhdGV3YXkudjEuQXJjaGVzdHJB",
|
||||
"VXNlclRvSWRSZXBseUgAEj8KDXNlc3Npb25fc3RhdGUYZCABKAsyJi5teGFj",
|
||||
"Y2Vzc19nYXRld2F5LnYxLlNlc3Npb25TdGF0ZVJlcGx5SAASOwoLd29ya2Vy",
|
||||
"X2luZm8YZSABKAsyJC5teGFjY2Vzc19nYXRld2F5LnYxLldvcmtlckluZm9S",
|
||||
"ZXBseUgAEj0KDGRyYWluX2V2ZW50cxhmIAEoCzIlLm14YWNjZXNzX2dhdGV3",
|
||||
"YXkudjEuRHJhaW5FdmVudHNSZXBseUgAQgkKB3BheWxvYWRCCgoIX2hyZXN1",
|
||||
"bHQiJgoNUmVnaXN0ZXJSZXBseRIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgFIiMK",
|
||||
"DEFkZEl0ZW1SZXBseRITCgtpdGVtX2hhbmRsZRgBIAEoBSIkCg1BZGRJdGVt",
|
||||
"MlJlcGx5EhMKC2l0ZW1faGFuZGxlGAEgASgFIisKFEFkZEJ1ZmZlcmVkSXRl",
|
||||
"bVJlcGx5EhMKC2l0ZW1faGFuZGxlGAEgASgFIkIKDFN1c3BlbmRSZXBseRIy",
|
||||
"CgZzdGF0dXMYASABKAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLk14U3RhdHVz",
|
||||
"UHJveHkiQwoNQWN0aXZhdGVSZXBseRIyCgZzdGF0dXMYASABKAsyIi5teGFj",
|
||||
"Y2Vzc19nYXRld2F5LnYxLk14U3RhdHVzUHJveHkiKAoVQXV0aGVudGljYXRl",
|
||||
"VXNlclJlcGx5Eg8KB3VzZXJfaWQYASABKAUiKQoWQXJjaGVzdHJBVXNlclRv",
|
||||
"SWRSZXBseRIPCgd1c2VyX2lkGAEgASgFIkUKEVNlc3Npb25TdGF0ZVJlcGx5",
|
||||
"EjAKBXN0YXRlGAEgASgOMiEubXhhY2Nlc3NfZ2F0ZXdheS52MS5TZXNzaW9u",
|
||||
"U3RhdGUidQoPV29ya2VySW5mb1JlcGx5EhkKEXdvcmtlcl9wcm9jZXNzX2lk",
|
||||
"GAEgASgFEhYKDndvcmtlcl92ZXJzaW9uGAIgASgJEhcKD214YWNjZXNzX3By",
|
||||
"b2dpZBgDIAEoCRIWCg5teGFjY2Vzc19jbHNpZBgEIAEoCSJAChBEcmFpbkV2",
|
||||
"ZW50c1JlcGx5EiwKBmV2ZW50cxgBIAMoCzIcLm14YWNjZXNzX2dhdGV3YXku",
|
||||
"djEuTXhFdmVudCKbBgoHTXhFdmVudBIyCgZmYW1pbHkYASABKA4yIi5teGFj",
|
||||
"Y2Vzc19nYXRld2F5LnYxLk14RXZlbnRGYW1pbHkSEgoKc2Vzc2lvbl9pZBgC",
|
||||
"IAEoCRIVCg1zZXJ2ZXJfaGFuZGxlGAMgASgFEhMKC2l0ZW1faGFuZGxlGAQg",
|
||||
"ASgFEisKBXZhbHVlGAUgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZh",
|
||||
"bHVlEg8KB3F1YWxpdHkYBiABKAUSNAoQc291cmNlX3RpbWVzdGFtcBgHIAEo",
|
||||
"CzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASNAoIc3RhdHVzZXMYCCAD",
|
||||
"KAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLk14U3RhdHVzUHJveHkSFwoPd29y",
|
||||
"a2VyX3NlcXVlbmNlGAkgASgEEjQKEHdvcmtlcl90aW1lc3RhbXAYCiABKAsy",
|
||||
"Gi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEj0KGWdhdGV3YXlfcmVjZWl2",
|
||||
"ZV90aW1lc3RhbXAYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1w",
|
||||
"EhQKB2hyZXN1bHQYDCABKAVIAYgBARISCgpyYXdfc3RhdHVzGA0gASgJEkAK",
|
||||
"Dm9uX2RhdGFfY2hhbmdlGBQgASgLMiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5P",
|
||||
"bkRhdGFDaGFuZ2VFdmVudEgAEkYKEW9uX3dyaXRlX2NvbXBsZXRlGBUgASgL",
|
||||
"MikubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbldyaXRlQ29tcGxldGVFdmVudEgA",
|
||||
"EkkKEm9wZXJhdGlvbl9jb21wbGV0ZRgWIAEoCzIrLm14YWNjZXNzX2dhdGV3",
|
||||
"YXkudjEuT3BlcmF0aW9uQ29tcGxldGVFdmVudEgAElEKF29uX2J1ZmZlcmVk",
|
||||
"X2RhdGFfY2hhbmdlGBcgASgLMi4ubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkJ1",
|
||||
"ZmZlcmVkRGF0YUNoYW5nZUV2ZW50SABCBgoEYm9keUIKCghfaHJlc3VsdCIT",
|
||||
"ChFPbkRhdGFDaGFuZ2VFdmVudCIWChRPbldyaXRlQ29tcGxldGVFdmVudCIY",
|
||||
"ChZPcGVyYXRpb25Db21wbGV0ZUV2ZW50ItQBChlPbkJ1ZmZlcmVkRGF0YUNo",
|
||||
"YW5nZUV2ZW50EjIKCWRhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3",
|
||||
"YXkudjEuTXhEYXRhVHlwZRI0Cg5xdWFsaXR5X3ZhbHVlcxgCIAEoCzIcLm14",
|
||||
"YWNjZXNzX2dhdGV3YXkudjEuTXhBcnJheRI2ChB0aW1lc3RhbXBfdmFsdWVz",
|
||||
"GAMgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeEFycmF5EhUKDXJhd19k",
|
||||
"YXRhX3R5cGUYBCABKAUi6wEKDU14U3RhdHVzUHJveHkSDwoHc3VjY2VzcxgB",
|
||||
"IAEoBRI3CghjYXRlZ29yeRgCIAEoDjIlLm14YWNjZXNzX2dhdGV3YXkudjEu",
|
||||
"TXhTdGF0dXNDYXRlZ29yeRI4CgtkZXRlY3RlZF9ieRgDIAEoDjIjLm14YWNj",
|
||||
"ZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNTb3VyY2USDgoGZGV0YWlsGAQgASgF",
|
||||
"EhQKDHJhd19jYXRlZ29yeRgFIAEoBRIXCg9yYXdfZGV0ZWN0ZWRfYnkYBiAB",
|
||||
"KAUSFwoPZGlhZ25vc3RpY190ZXh0GAcgASgJIqcDCgdNeFZhbHVlEjIKCWRh",
|
||||
"dGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3YXkudjEuTXhEYXRhVHlw",
|
||||
"ZRIUCgx2YXJpYW50X3R5cGUYAiABKAkSDwoHaXNfbnVsbBgDIAEoCBIWCg5y",
|
||||
"YXdfZGlhZ25vc3RpYxgEIAEoCRIVCg1yYXdfZGF0YV90eXBlGAUgASgFEhQK",
|
||||
"CmJvb2xfdmFsdWUYCiABKAhIABIVCgtpbnQzMl92YWx1ZRgLIAEoBUgAEhUK",
|
||||
"C2ludDY0X3ZhbHVlGAwgASgDSAASFQoLZmxvYXRfdmFsdWUYDSABKAJIABIW",
|
||||
"Cgxkb3VibGVfdmFsdWUYDiABKAFIABIWCgxzdHJpbmdfdmFsdWUYDyABKAlI",
|
||||
"ABI1Cg90aW1lc3RhbXBfdmFsdWUYECABKAsyGi5nb29nbGUucHJvdG9idWYu",
|
||||
"VGltZXN0YW1wSAASMwoLYXJyYXlfdmFsdWUYESABKAsyHC5teGFjY2Vzc19n",
|
||||
"YXRld2F5LnYxLk14QXJyYXlIABITCglyYXdfdmFsdWUYEiABKAxIAEIGCgRr",
|
||||
"aW5kIv4ECgdNeEFycmF5EjoKEWVsZW1lbnRfZGF0YV90eXBlGAEgASgOMh8u",
|
||||
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEhQKDHZhcmlhbnRfdHlw",
|
||||
"ZRgCIAEoCRISCgpkaW1lbnNpb25zGAMgAygNEhYKDnJhd19kaWFnbm9zdGlj",
|
||||
"GAQgASgJEh0KFXJhd19lbGVtZW50X2RhdGFfdHlwZRgFIAEoBRI1Cgtib29s",
|
||||
"X3ZhbHVlcxgKIAEoCzIeLm14YWNjZXNzX2dhdGV3YXkudjEuQm9vbEFycmF5",
|
||||
"SAASNwoMaW50MzJfdmFsdWVzGAsgASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52",
|
||||
"MS5JbnQzMkFycmF5SAASNwoMaW50NjRfdmFsdWVzGAwgASgLMh8ubXhhY2Nl",
|
||||
"c3NfZ2F0ZXdheS52MS5JbnQ2NEFycmF5SAASNwoMZmxvYXRfdmFsdWVzGA0g",
|
||||
"ASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5GbG9hdEFycmF5SAASOQoNZG91",
|
||||
"YmxlX3ZhbHVlcxgOIAEoCzIgLm14YWNjZXNzX2dhdGV3YXkudjEuRG91Ymxl",
|
||||
"QXJyYXlIABI5Cg1zdHJpbmdfdmFsdWVzGA8gASgLMiAubXhhY2Nlc3NfZ2F0",
|
||||
"ZXdheS52MS5TdHJpbmdBcnJheUgAEj8KEHRpbWVzdGFtcF92YWx1ZXMYECAB",
|
||||
"KAsyIy5teGFjY2Vzc19nYXRld2F5LnYxLlRpbWVzdGFtcEFycmF5SAASMwoK",
|
||||
"cmF3X3ZhbHVlcxgRIAEoCzIdLm14YWNjZXNzX2dhdGV3YXkudjEuUmF3QXJy",
|
||||
"YXlIAEIICgZ2YWx1ZXMiGwoJQm9vbEFycmF5Eg4KBnZhbHVlcxgBIAMoCCIc",
|
||||
"CgpJbnQzMkFycmF5Eg4KBnZhbHVlcxgBIAMoBSIcCgpJbnQ2NEFycmF5Eg4K",
|
||||
"BnZhbHVlcxgBIAMoAyIcCgpGbG9hdEFycmF5Eg4KBnZhbHVlcxgBIAMoAiId",
|
||||
"CgtEb3VibGVBcnJheRIOCgZ2YWx1ZXMYASADKAEiHQoLU3RyaW5nQXJyYXkS",
|
||||
"DgoGdmFsdWVzGAEgAygJIjwKDlRpbWVzdGFtcEFycmF5EioKBnZhbHVlcxgB",
|
||||
"IAMoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiGgoIUmF3QXJyYXkS",
|
||||
"DgoGdmFsdWVzGAEgAygMIlgKDlByb3RvY29sU3RhdHVzEjUKBGNvZGUYASAB",
|
||||
"KA4yJy5teGFjY2Vzc19nYXRld2F5LnYxLlByb3RvY29sU3RhdHVzQ29kZRIP",
|
||||
"CgdtZXNzYWdlGAIgASgJKr8GCg1NeENvbW1hbmRLaW5kEh8KG01YX0NPTU1B",
|
||||
"TkRfS0lORF9VTlNQRUNJRklFRBAAEhwKGE1YX0NPTU1BTkRfS0lORF9SRUdJ",
|
||||
"U1RFUhABEh4KGk1YX0NPTU1BTkRfS0lORF9VTlJFR0lTVEVSEAISHAoYTVhf",
|
||||
"Q09NTUFORF9LSU5EX0FERF9JVEVNEAMSHQoZTVhfQ09NTUFORF9LSU5EX0FE",
|
||||
"RF9JVEVNMhAEEh8KG01YX0NPTU1BTkRfS0lORF9SRU1PVkVfSVRFTRAFEhoK",
|
||||
"Fk1YX0NPTU1BTkRfS0lORF9BRFZJU0UQBhIdChlNWF9DT01NQU5EX0tJTkRf",
|
||||
"VU5fQURWSVNFEAcSJgoiTVhfQ09NTUFORF9LSU5EX0FEVklTRV9TVVBFUlZJ",
|
||||
"U09SWRAIEiUKIU1YX0NPTU1BTkRfS0lORF9BRERfQlVGRkVSRURfSVRFTRAJ",
|
||||
"EjAKLE1YX0NPTU1BTkRfS0lORF9TRVRfQlVGRkVSRURfVVBEQVRFX0lOVEVS",
|
||||
"VkFMEAoSGwoXTVhfQ09NTUFORF9LSU5EX1NVU1BFTkQQCxIcChhNWF9DT01N",
|
||||
"QU5EX0tJTkRfQUNUSVZBVEUQDBIZChVNWF9DT01NQU5EX0tJTkRfV1JJVEUQ",
|
||||
"DRIaChZNWF9DT01NQU5EX0tJTkRfV1JJVEUyEA4SIQodTVhfQ09NTUFORF9L",
|
||||
"SU5EX1dSSVRFX1NFQ1VSRUQQDxIiCh5NWF9DT01NQU5EX0tJTkRfV1JJVEVf",
|
||||
"U0VDVVJFRDIQEBIlCiFNWF9DT01NQU5EX0tJTkRfQVVUSEVOVElDQVRFX1VT",
|
||||
"RVIQERIoCiRNWF9DT01NQU5EX0tJTkRfQVJDSEVTVFJBX1VTRVJfVE9fSUQQ",
|
||||
"EhIYChRNWF9DT01NQU5EX0tJTkRfUElORxBkEiUKIU1YX0NPTU1BTkRfS0lO",
|
||||
"RF9HRVRfU0VTU0lPTl9TVEFURRBlEiMKH01YX0NPTU1BTkRfS0lORF9HRVRf",
|
||||
"V09SS0VSX0lORk8QZhIgChxNWF9DT01NQU5EX0tJTkRfRFJBSU5fRVZFTlRT",
|
||||
"EGcSIwofTVhfQ09NTUFORF9LSU5EX1NIVVRET1dOX1dPUktFUhBoKtABCg1N",
|
||||
"eEV2ZW50RmFtaWx5Eh8KG01YX0VWRU5UX0ZBTUlMWV9VTlNQRUNJRklFRBAA",
|
||||
"EiIKHk1YX0VWRU5UX0ZBTUlMWV9PTl9EQVRBX0NIQU5HRRABEiUKIU1YX0VW",
|
||||
"RU5UX0ZBTUlMWV9PTl9XUklURV9DT01QTEVURRACEiYKIk1YX0VWRU5UX0ZB",
|
||||
"TUlMWV9PUEVSQVRJT05fQ09NUExFVEUQAxIrCidNWF9FVkVOVF9GQU1JTFlf",
|
||||
"T05fQlVGRkVSRURfREFUQV9DSEFOR0UQBCqlAwoQTXhTdGF0dXNDYXRlZ29y",
|
||||
"eRIiCh5NWF9TVEFUVVNfQ0FURUdPUllfVU5TUEVDSUZJRUQQABIeChpNWF9T",
|
||||
"VEFUVVNfQ0FURUdPUllfVU5LTk9XThABEhkKFU1YX1NUQVRVU19DQVRFR09S",
|
||||
"WV9PSxACEh4KGk1YX1NUQVRVU19DQVRFR09SWV9QRU5ESU5HEAMSHgoaTVhf",
|
||||
"U1RBVFVTX0NBVEVHT1JZX1dBUk5JTkcQBBIqCiZNWF9TVEFUVVNfQ0FURUdP",
|
||||
"UllfQ09NTVVOSUNBVElPTl9FUlJPUhAFEioKJk1YX1NUQVRVU19DQVRFR09S",
|
||||
"WV9DT05GSUdVUkFUSU9OX0VSUk9SEAYSKAokTVhfU1RBVFVTX0NBVEVHT1JZ",
|
||||
"X09QRVJBVElPTkFMX0VSUk9SEAcSJQohTVhfU1RBVFVTX0NBVEVHT1JZX1NF",
|
||||
"Q1VSSVRZX0VSUk9SEAgSJQohTVhfU1RBVFVTX0NBVEVHT1JZX1NPRlRXQVJF",
|
||||
"X0VSUk9SEAkSIgoeTVhfU1RBVFVTX0NBVEVHT1JZX09USEVSX0VSUk9SEAoq",
|
||||
"ygIKDk14U3RhdHVzU291cmNlEiAKHE1YX1NUQVRVU19TT1VSQ0VfVU5TUEVD",
|
||||
"SUZJRUQQABIcChhNWF9TVEFUVVNfU09VUkNFX1VOS05PV04QARIjCh9NWF9T",
|
||||
"VEFUVVNfU09VUkNFX1JFUVVFU1RJTkdfTE1YEAISIwofTVhfU1RBVFVTX1NP",
|
||||
"VVJDRV9SRVNQT05ESU5HX0xNWBADEiMKH01YX1NUQVRVU19TT1VSQ0VfUkVR",
|
||||
"VUVTVElOR19OTVgQBBIjCh9NWF9TVEFUVVNfU09VUkNFX1JFU1BPTkRJTkdf",
|
||||
"Tk1YEAUSMQotTVhfU1RBVFVTX1NPVVJDRV9SRVFVRVNUSU5HX0FVVE9NQVRJ",
|
||||
"T05fT0JKRUNUEAYSMQotTVhfU1RBVFVTX1NPVVJDRV9SRVNQT05ESU5HX0FV",
|
||||
"VE9NQVRJT05fT0JKRUNUEAcq3QQKCk14RGF0YVR5cGUSHAoYTVhfREFUQV9U",
|
||||
"WVBFX1VOU1BFQ0lGSUVEEAASGAoUTVhfREFUQV9UWVBFX1VOS05PV04QARIY",
|
||||
"ChRNWF9EQVRBX1RZUEVfTk9fREFUQRACEhgKFE1YX0RBVEFfVFlQRV9CT09M",
|
||||
"RUFOEAMSGAoUTVhfREFUQV9UWVBFX0lOVEVHRVIQBBIWChJNWF9EQVRBX1RZ",
|
||||
"UEVfRkxPQVQQBRIXChNNWF9EQVRBX1RZUEVfRE9VQkxFEAYSFwoTTVhfREFU",
|
||||
"QV9UWVBFX1NUUklORxAHEhUKEU1YX0RBVEFfVFlQRV9USU1FEAgSHQoZTVhf",
|
||||
"REFUQV9UWVBFX0VMQVBTRURfVElNRRAJEh8KG01YX0RBVEFfVFlQRV9SRUZF",
|
||||
"UkVOQ0VfVFlQRRAKEhwKGE1YX0RBVEFfVFlQRV9TVEFUVVNfVFlQRRALEhUK",
|
||||
"EU1YX0RBVEFfVFlQRV9FTlVNEAwSLQopTVhfREFUQV9UWVBFX1NFQ1VSSVRZ",
|
||||
"X0NMQVNTSUZJQ0FUSU9OX0VOVU0QDRIiCh5NWF9EQVRBX1RZUEVfREFUQV9R",
|
||||
"VUFMSVRZX1RZUEUQDhIfChtNWF9EQVRBX1RZUEVfUVVBTElGSUVEX0VOVU0Q",
|
||||
"DxIhCh1NWF9EQVRBX1RZUEVfUVVBTElGSUVEX1NUUlVDVBAQEikKJU1YX0RB",
|
||||
"VEFfVFlQRV9JTlRFUk5BVElPTkFMSVpFRF9TVFJJTkcQERIbChdNWF9EQVRB",
|
||||
"X1RZUEVfQklHX1NUUklORxASEhQKEE1YX0RBVEFfVFlQRV9FTkQQEyqjAwoS",
|
||||
"UHJvdG9jb2xTdGF0dXNDb2RlEiQKIFBST1RPQ09MX1NUQVRVU19DT0RFX1VO",
|
||||
"U1BFQ0lGSUVEEAASGwoXUFJPVE9DT0xfU1RBVFVTX0NPREVfT0sQARIoCiRQ",
|
||||
"Uk9UT0NPTF9TVEFUVVNfQ09ERV9JTlZBTElEX1JFUVVFU1QQAhIqCiZQUk9U",
|
||||
"T0NPTF9TVEFUVVNfQ09ERV9TRVNTSU9OX05PVF9GT1VORBADEioKJlBST1RP",
|
||||
"Q09MX1NUQVRVU19DT0RFX1NFU1NJT05fTk9UX1JFQURZEAQSKwonUFJPVE9D",
|
||||
"T0xfU1RBVFVTX0NPREVfV09SS0VSX1VOQVZBSUxBQkxFEAUSIAocUFJPVE9D",
|
||||
"T0xfU1RBVFVTX0NPREVfVElNRU9VVBAGEiEKHVBST1RPQ09MX1NUQVRVU19D",
|
||||
"T0RFX0NBTkNFTEVEEAcSKwonUFJPVE9DT0xfU1RBVFVTX0NPREVfUFJPVE9D",
|
||||
"T0xfVklPTEFUSU9OEAgSKQolUFJPVE9DT0xfU1RBVFVTX0NPREVfTVhBQ0NF",
|
||||
"U1NfRkFJTFVSRRAJKr8CCgxTZXNzaW9uU3RhdGUSHQoZU0VTU0lPTl9TVEFU",
|
||||
"RV9VTlNQRUNJRklFRBAAEhoKFlNFU1NJT05fU1RBVEVfQ1JFQVRJTkcQARIh",
|
||||
"Ch1TRVNTSU9OX1NUQVRFX1NUQVJUSU5HX1dPUktFUhACEiIKHlNFU1NJT05f",
|
||||
"U1RBVEVfV0FJVElOR19GT1JfUElQRRADEh0KGVNFU1NJT05fU1RBVEVfSEFO",
|
||||
"RFNIQUtJTkcQBBIlCiFTRVNTSU9OX1NUQVRFX0lOSVRJQUxJWklOR19XT1JL",
|
||||
"RVIQBRIXChNTRVNTSU9OX1NUQVRFX1JFQURZEAYSGQoVU0VTU0lPTl9TVEFU",
|
||||
"RV9DTE9TSU5HEAcSGAoUU0VTU0lPTl9TVEFURV9DTE9TRUQQCBIZChVTRVNT",
|
||||
"SU9OX1NUQVRFX0ZBVUxURUQQCTKCAwoPTXhBY2Nlc3NHYXRld2F5El0KC09w",
|
||||
"ZW5TZXNzaW9uEicubXhhY2Nlc3NfZ2F0ZXdheS52MS5PcGVuU2Vzc2lvblJl",
|
||||
"cXVlc3QaJS5teGFjY2Vzc19nYXRld2F5LnYxLk9wZW5TZXNzaW9uUmVwbHkS",
|
||||
"YAoMQ2xvc2VTZXNzaW9uEigubXhhY2Nlc3NfZ2F0ZXdheS52MS5DbG9zZVNl",
|
||||
"c3Npb25SZXF1ZXN0GiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5DbG9zZVNlc3Np",
|
||||
"b25SZXBseRJUCgZJbnZva2USJS5teGFjY2Vzc19nYXRld2F5LnYxLk14Q29t",
|
||||
"bWFuZFJlcXVlc3QaIy5teGFjY2Vzc19nYXRld2F5LnYxLk14Q29tbWFuZFJl",
|
||||
"cGx5ElgKDFN0cmVhbUV2ZW50cxIoLm14YWNjZXNzX2dhdGV3YXkudjEuU3Ry",
|
||||
"ZWFtRXZlbnRzUmVxdWVzdBocLm14YWNjZXNzX2dhdGV3YXkudjEuTXhFdmVu",
|
||||
"dDABQhyqAhlNeEdhdGV3YXkuQ29udHJhY3RzLlByb3RvYgZwcm90bzM="));
|
||||
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
|
||||
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.DurationReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, },
|
||||
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::MxGateway.Contracts.Proto.MxCommandKind), typeof(global::MxGateway.Contracts.Proto.MxEventFamily), typeof(global::MxGateway.Contracts.Proto.MxStatusCategory), typeof(global::MxGateway.Contracts.Proto.MxStatusSource), typeof(global::MxGateway.Contracts.Proto.MxDataType), typeof(global::MxGateway.Contracts.Proto.ProtocolStatusCode), typeof(global::MxGateway.Contracts.Proto.SessionState), }, null, new pbr::GeneratedClrTypeInfo[] {
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OpenSessionRequest), global::MxGateway.Contracts.Proto.OpenSessionRequest.Parser, new[]{ "RequestedBackend", "ClientSessionName", "ClientCorrelationId", "CommandTimeout" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OpenSessionReply), global::MxGateway.Contracts.Proto.OpenSessionReply.Parser, new[]{ "SessionId", "BackendName", "WorkerProcessId", "WorkerProtocolVersion", "Capabilities", "DefaultCommandTimeout", "ProtocolStatus" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OpenSessionReply), global::MxGateway.Contracts.Proto.OpenSessionReply.Parser, new[]{ "SessionId", "BackendName", "WorkerProcessId", "WorkerProtocolVersion", "Capabilities", "DefaultCommandTimeout", "ProtocolStatus", "GatewayProtocolVersion" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.CloseSessionRequest), global::MxGateway.Contracts.Proto.CloseSessionRequest.Parser, new[]{ "SessionId", "ClientCorrelationId" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.CloseSessionReply), global::MxGateway.Contracts.Proto.CloseSessionReply.Parser, new[]{ "SessionId", "FinalState", "ProtocolStatus" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.StreamEventsRequest), global::MxGateway.Contracts.Proto.StreamEventsRequest.Parser, new[]{ "SessionId", "AfterWorkerSequence" }, null, null, null, null),
|
||||
@@ -841,6 +841,7 @@ namespace MxGateway.Contracts.Proto {
|
||||
capabilities_ = other.capabilities_.Clone();
|
||||
defaultCommandTimeout_ = other.defaultCommandTimeout_ != null ? other.defaultCommandTimeout_.Clone() : null;
|
||||
protocolStatus_ = other.protocolStatus_ != null ? other.protocolStatus_.Clone() : null;
|
||||
gatewayProtocolVersion_ = other.gatewayProtocolVersion_;
|
||||
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -933,6 +934,23 @@ namespace MxGateway.Contracts.Proto {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "gateway_protocol_version" field.</summary>
|
||||
public const int GatewayProtocolVersionFieldNumber = 8;
|
||||
private uint gatewayProtocolVersion_;
|
||||
/// <summary>
|
||||
/// Public gateway contract version implemented by this endpoint. Clients use
|
||||
/// this value to reject incompatible generated-code inputs before issuing
|
||||
/// command-specific MXAccess calls.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public uint GatewayProtocolVersion {
|
||||
get { return gatewayProtocolVersion_; }
|
||||
set {
|
||||
gatewayProtocolVersion_ = value;
|
||||
}
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public override bool Equals(object other) {
|
||||
@@ -955,6 +973,7 @@ namespace MxGateway.Contracts.Proto {
|
||||
if(!capabilities_.Equals(other.capabilities_)) return false;
|
||||
if (!object.Equals(DefaultCommandTimeout, other.DefaultCommandTimeout)) return false;
|
||||
if (!object.Equals(ProtocolStatus, other.ProtocolStatus)) return false;
|
||||
if (GatewayProtocolVersion != other.GatewayProtocolVersion) return false;
|
||||
return Equals(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -969,6 +988,7 @@ namespace MxGateway.Contracts.Proto {
|
||||
hash ^= capabilities_.GetHashCode();
|
||||
if (defaultCommandTimeout_ != null) hash ^= DefaultCommandTimeout.GetHashCode();
|
||||
if (protocolStatus_ != null) hash ^= ProtocolStatus.GetHashCode();
|
||||
if (GatewayProtocolVersion != 0) hash ^= GatewayProtocolVersion.GetHashCode();
|
||||
if (_unknownFields != null) {
|
||||
hash ^= _unknownFields.GetHashCode();
|
||||
}
|
||||
@@ -1012,6 +1032,10 @@ namespace MxGateway.Contracts.Proto {
|
||||
output.WriteRawTag(58);
|
||||
output.WriteMessage(ProtocolStatus);
|
||||
}
|
||||
if (GatewayProtocolVersion != 0) {
|
||||
output.WriteRawTag(64);
|
||||
output.WriteUInt32(GatewayProtocolVersion);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(output);
|
||||
}
|
||||
@@ -1047,6 +1071,10 @@ namespace MxGateway.Contracts.Proto {
|
||||
output.WriteRawTag(58);
|
||||
output.WriteMessage(ProtocolStatus);
|
||||
}
|
||||
if (GatewayProtocolVersion != 0) {
|
||||
output.WriteRawTag(64);
|
||||
output.WriteUInt32(GatewayProtocolVersion);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(ref output);
|
||||
}
|
||||
@@ -1076,6 +1104,9 @@ namespace MxGateway.Contracts.Proto {
|
||||
if (protocolStatus_ != null) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeMessageSize(ProtocolStatus);
|
||||
}
|
||||
if (GatewayProtocolVersion != 0) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeUInt32Size(GatewayProtocolVersion);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
size += _unknownFields.CalculateSize();
|
||||
}
|
||||
@@ -1113,6 +1144,9 @@ namespace MxGateway.Contracts.Proto {
|
||||
}
|
||||
ProtocolStatus.MergeFrom(other.ProtocolStatus);
|
||||
}
|
||||
if (other.GatewayProtocolVersion != 0) {
|
||||
GatewayProtocolVersion = other.GatewayProtocolVersion;
|
||||
}
|
||||
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -1166,6 +1200,10 @@ namespace MxGateway.Contracts.Proto {
|
||||
input.ReadMessage(ProtocolStatus);
|
||||
break;
|
||||
}
|
||||
case 64: {
|
||||
GatewayProtocolVersion = input.ReadUInt32();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1219,6 +1257,10 @@ namespace MxGateway.Contracts.Proto {
|
||||
input.ReadMessage(ProtocolStatus);
|
||||
break;
|
||||
}
|
||||
case 64: {
|
||||
GatewayProtocolVersion = input.ReadUInt32();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ message OpenSessionReply {
|
||||
repeated string capabilities = 5;
|
||||
google.protobuf.Duration default_command_timeout = 6;
|
||||
ProtocolStatus protocol_status = 7;
|
||||
// Public gateway contract version implemented by this endpoint. Clients use
|
||||
// this value to reject incompatible generated-code inputs before issuing
|
||||
// command-specific MXAccess calls.
|
||||
uint32 gateway_protocol_version = 8;
|
||||
}
|
||||
|
||||
message CloseSessionRequest {
|
||||
|
||||
@@ -35,6 +35,7 @@ public sealed class MxAccessGatewayService(
|
||||
BackendName = session.BackendName,
|
||||
WorkerProcessId = session.WorkerProcessId ?? 0,
|
||||
WorkerProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||
GatewayProtocolVersion = GatewayContractInfo.GatewayProtocolVersion,
|
||||
DefaultCommandTimeout = Google.Protobuf.WellKnownTypes.Duration.FromTimeSpan(session.CommandTimeout),
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
};
|
||||
|
||||
@@ -227,6 +227,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
try
|
||||
{
|
||||
await WaitForBackgroundTasksAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
await WaitForProcessExitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
MarkClosed("shutdown");
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
@@ -717,6 +718,17 @@ public sealed class WorkerClient : IWorkerClient
|
||||
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()
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Text.Json;
|
||||
using Google.Protobuf;
|
||||
using MxGateway.Contracts;
|
||||
using MxGateway.Contracts.Proto;
|
||||
|
||||
namespace MxGateway.Tests.Contracts;
|
||||
|
||||
public sealed class ClientProtoInputTests
|
||||
{
|
||||
[Fact]
|
||||
public void Manifest_DeclaresCurrentProtocolVersionsAndExistingInputs()
|
||||
{
|
||||
DirectoryInfo repositoryRoot = FindRepositoryRoot();
|
||||
string manifestPath = Path.Combine(repositoryRoot.FullName, "clients", "proto", "proto-inputs.json");
|
||||
|
||||
using JsonDocument manifest = JsonDocument.Parse(File.ReadAllText(manifestPath));
|
||||
JsonElement root = manifest.RootElement;
|
||||
|
||||
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
|
||||
Assert.Equal(GatewayContractInfo.GatewayProtocolVersion, root.GetProperty("gatewayProtocolVersion").GetUInt32());
|
||||
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, root.GetProperty("workerProtocolVersion").GetUInt32());
|
||||
|
||||
string protoRoot = Path.Combine(repositoryRoot.FullName, root.GetProperty("protoRoot").GetString()!);
|
||||
foreach (JsonElement sourceFile in root.GetProperty("sourceFiles").EnumerateArray())
|
||||
{
|
||||
string sourcePath = Path.Combine(protoRoot, sourceFile.GetProperty("path").GetString()!);
|
||||
Assert.True(File.Exists(sourcePath), $"Expected proto source file '{sourcePath}' to exist.");
|
||||
}
|
||||
|
||||
foreach (JsonProperty output in root.GetProperty("generatedOutputs").EnumerateObject())
|
||||
{
|
||||
string outputPath = Path.Combine(repositoryRoot.FullName, output.Value.GetString()!);
|
||||
Assert.True(Directory.Exists(outputPath), $"Expected generated output directory '{outputPath}' to exist.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenSessionReplyFixture_ParsesWithCurrentContract()
|
||||
{
|
||||
OpenSessionReply reply = ParseFixture(
|
||||
"open-session-reply.ok.json",
|
||||
OpenSessionReply.Parser);
|
||||
|
||||
Assert.Equal(GatewayContractInfo.GatewayProtocolVersion, reply.GatewayProtocolVersion);
|
||||
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, reply.WorkerProtocolVersion);
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterCommandRequestFixture_ParsesWithCurrentContract()
|
||||
{
|
||||
MxCommandRequest request = ParseFixture(
|
||||
"register-command-request.json",
|
||||
MxCommandRequest.Parser);
|
||||
|
||||
Assert.Equal(MxCommandKind.Register, request.Command.Kind);
|
||||
Assert.Equal("fixture-client", request.Command.Register.ClientName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnDataChangeEventFixture_ParsesWithCurrentContract()
|
||||
{
|
||||
MxEvent gatewayEvent = ParseFixture(
|
||||
"on-data-change-event.json",
|
||||
MxEvent.Parser);
|
||||
|
||||
Assert.Equal(MxEventFamily.OnDataChange, gatewayEvent.Family);
|
||||
Assert.Equal(1ul, gatewayEvent.WorkerSequence);
|
||||
Assert.Equal(MxDataType.Integer, gatewayEvent.Value.DataType);
|
||||
Assert.Equal(MxEvent.BodyOneofCase.OnDataChange, gatewayEvent.BodyCase);
|
||||
}
|
||||
|
||||
private static T ParseFixture<T>(
|
||||
string fixtureName,
|
||||
MessageParser<T> parser)
|
||||
where T : IMessage<T>
|
||||
{
|
||||
DirectoryInfo repositoryRoot = FindRepositoryRoot();
|
||||
string fixturePath = Path.Combine(repositoryRoot.FullName, "clients", "proto", "fixtures", "golden", fixtureName);
|
||||
|
||||
return parser.ParseJson(File.ReadAllText(fixturePath));
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,12 @@ public sealed class GatewayContractInfoTests
|
||||
Assert.Equal("mxaccess-worker", GatewayContractInfo.DefaultBackendName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GatewayProtocolVersion_StartsAtVersionOne()
|
||||
{
|
||||
Assert.Equal(1u, GatewayContractInfo.GatewayProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkerProtocolVersion_StartsAtVersionOne()
|
||||
{
|
||||
|
||||
@@ -37,6 +37,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
Assert.Equal(GatewayContractInfo.DefaultBackendName, reply.BackendName);
|
||||
Assert.Equal(4321, reply.WorkerProcessId);
|
||||
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, reply.WorkerProtocolVersion);
|
||||
Assert.Equal(GatewayContractInfo.GatewayProtocolVersion, reply.GatewayProtocolVersion);
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.Contains("unary-invoke", reply.Capabilities);
|
||||
Assert.Equal("Operator Key", sessionManager.LastClientIdentity);
|
||||
|
||||
@@ -86,6 +86,26 @@ public sealed class SessionManagerTests
|
||||
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]
|
||||
public async Task OpenSessionAsync_WhenWorkerCreationFails_RemovesSessionFromRegistry()
|
||||
{
|
||||
@@ -266,6 +286,8 @@ public sealed class SessionManagerTests
|
||||
|
||||
public int KillCount { get; private set; }
|
||||
|
||||
public Exception? ShutdownException { get; init; }
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
@@ -302,6 +324,11 @@ public sealed class SessionManagerTests
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ShutdownCount++;
|
||||
if (ShutdownException is not null)
|
||||
{
|
||||
throw ShutdownException;
|
||||
}
|
||||
|
||||
State = WorkerClientState.Closed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,25 @@ public sealed class FakeWorkerHarnessTests
|
||||
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]
|
||||
public async Task InvokeAsync_WithHungWorker_TimesOutPendingCommand()
|
||||
{
|
||||
|
||||
@@ -284,6 +284,26 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
|
||||
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(
|
||||
ProtocolStatusCode statusCode = ProtocolStatusCode.Ok,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.IO.Pipes;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Server.Workers;
|
||||
@@ -151,6 +152,24 @@ public sealed class WorkerClientTests
|
||||
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]
|
||||
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(
|
||||
string correlationId,
|
||||
ulong sequence,
|
||||
|
||||
@@ -30,7 +30,7 @@ public sealed class WorkerApplicationTests
|
||||
Assert.Equal("mxaccess-gateway-123-session-1", entry.Fields["pipe_name"]);
|
||||
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, entry.Fields["protocol_version"]);
|
||||
Assert.Equal("[redacted]", entry.Fields["nonce"]);
|
||||
Assert.Equal("WorkerPipeHandshakeSucceeded", logger.Entries[1].EventName);
|
||||
Assert.Equal("WorkerPipeSessionCompleted", logger.Entries[1].EventName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.Bootstrap;
|
||||
using MxGateway.Worker.Ipc;
|
||||
using MxGateway.Worker.MxAccess;
|
||||
using MxGateway.Worker.Sta;
|
||||
|
||||
namespace MxGateway.Worker.Tests.Ipc;
|
||||
|
||||
@@ -28,7 +33,9 @@ public sealed class WorkerPipeClientTests
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
WorkerPipeClient client = new(connectTimeoutMilliseconds: 5000);
|
||||
WorkerPipeClient client = new(
|
||||
connectTimeoutMilliseconds: 5000,
|
||||
(stream, options) => CreateSession(stream, options));
|
||||
Task clientTask = client.RunAsync(workerOptions);
|
||||
|
||||
await Task.Factory.FromAsync(server.BeginWaitForConnection, server.EndWaitForConnection, null);
|
||||
@@ -56,6 +63,94 @@ public sealed class WorkerPipeClientTests
|
||||
WorkerEnvelope ready = await reader.ReadAsync();
|
||||
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;
|
||||
}
|
||||
|
||||
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.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.Ipc;
|
||||
using MxGateway.Worker.MxAccess;
|
||||
using MxGateway.Worker.Sta;
|
||||
|
||||
namespace MxGateway.Worker.Tests.Ipc;
|
||||
|
||||
@@ -147,6 +152,127 @@ public sealed class WorkerPipeSessionTests
|
||||
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(
|
||||
Stream inbound,
|
||||
Stream outbound,
|
||||
@@ -159,6 +285,21 @@ public sealed class WorkerPipeSessionTests
|
||||
() => 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()
|
||||
{
|
||||
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(
|
||||
MemoryStream stream,
|
||||
WorkerFrameProtocolOptions options)
|
||||
@@ -219,4 +473,166 @@ public sealed class WorkerPipeSessionTests
|
||||
buffer[2] = (byte)(value >> 16);
|
||||
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.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using MxGateway.Contracts.Proto;
|
||||
@@ -177,6 +179,30 @@ public sealed class MxAccessCommandExecutorTests
|
||||
Assert.Empty(await session.GetRegisteredItemHandlesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_RemoveItemWithAdvisedHandle_RemovesTrackedAdviceAfterMxAccessSucceeds()
|
||||
{
|
||||
FakeMxAccessComObject fakeComObject = new(
|
||||
registerHandle: 148,
|
||||
addItemHandle: 603);
|
||||
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-advised-remove", "client-a"));
|
||||
await session.DispatchAsync(CreateAddItemCommand("add-before-advised-remove", 148, "Galaxy.Tag.Value"));
|
||||
await session.DispatchAsync(CreateAdviseCommand("advise-before-remove", 148, 603));
|
||||
|
||||
MxCommandReply reply = await session.DispatchAsync(CreateRemoveItemCommand(
|
||||
"remove-advised-item",
|
||||
148,
|
||||
603));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.Empty(await session.GetRegisteredItemHandlesAsync());
|
||||
Assert.Empty(await session.GetRegisteredAdviceHandlesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_RemoveItemWithCrossServerHandle_PreservesHResultAndKeepsTrackedItemHandle()
|
||||
{
|
||||
@@ -238,6 +264,209 @@ public sealed class MxAccessCommandExecutorTests
|
||||
Assert.Empty(await session.GetRegisteredItemHandlesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_Advise_CallsMxAccessOnStaAndTracksPlainAdvice()
|
||||
{
|
||||
FakeMxAccessComObject fakeComObject = new(
|
||||
registerHandle: 52,
|
||||
addItemHandle: 505);
|
||||
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-advise", "client-a"));
|
||||
await session.DispatchAsync(CreateAddItemCommand("add-before-advise", 52, "Galaxy.Tag.Value"));
|
||||
|
||||
MxCommandReply reply = await session.DispatchAsync(CreateAdviseCommand(
|
||||
"advise",
|
||||
52,
|
||||
505));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.True(reply.HasHresult);
|
||||
Assert.Equal(0, reply.Hresult);
|
||||
Assert.Equal(52, fakeComObject.AdviseServerHandle);
|
||||
Assert.Equal(505, fakeComObject.AdvisedItemHandle);
|
||||
Assert.Equal(runtime.StaThreadId, fakeComObject.AdviseThreadId);
|
||||
|
||||
RegisteredAdviceHandle adviceHandle = Assert.Single(
|
||||
await session.GetRegisteredAdviceHandlesAsync());
|
||||
Assert.Equal(52, adviceHandle.ServerHandle);
|
||||
Assert.Equal(505, adviceHandle.ItemHandle);
|
||||
Assert.Equal(MxAccessAdviceKind.Plain, adviceHandle.AdviceKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_AdviseSupervisory_CallsDistinctMxAccessMethodAndTracksSupervisoryAdvice()
|
||||
{
|
||||
FakeMxAccessComObject fakeComObject = new(
|
||||
registerHandle: 53,
|
||||
addItemHandle: 506);
|
||||
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-supervisory", "client-a"));
|
||||
await session.DispatchAsync(CreateAddItemCommand("add-before-supervisory", 53, "Galaxy.Tag.Value"));
|
||||
|
||||
MxCommandReply reply = await session.DispatchAsync(CreateAdviseSupervisoryCommand(
|
||||
"advise-supervisory",
|
||||
53,
|
||||
506));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.Equal(53, fakeComObject.AdviseSupervisoryServerHandle);
|
||||
Assert.Equal(506, fakeComObject.AdviseSupervisoryItemHandle);
|
||||
Assert.Equal(runtime.StaThreadId, fakeComObject.AdviseSupervisoryThreadId);
|
||||
Assert.Null(fakeComObject.AdviseServerHandle);
|
||||
|
||||
RegisteredAdviceHandle adviceHandle = Assert.Single(
|
||||
await session.GetRegisteredAdviceHandlesAsync());
|
||||
Assert.Equal(53, adviceHandle.ServerHandle);
|
||||
Assert.Equal(506, adviceHandle.ItemHandle);
|
||||
Assert.Equal(MxAccessAdviceKind.Supervisory, adviceHandle.AdviceKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_UnAdvise_CallsMxAccessOnStaAndRemovesTrackedAdvice()
|
||||
{
|
||||
FakeMxAccessComObject fakeComObject = new(
|
||||
registerHandle: 54,
|
||||
addItemHandle: 507);
|
||||
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-unadvise", "client-a"));
|
||||
await session.DispatchAsync(CreateAddItemCommand("add-before-unadvise", 54, "Galaxy.Tag.Value"));
|
||||
await session.DispatchAsync(CreateAdviseCommand("advise-before-unadvise", 54, 507));
|
||||
await session.DispatchAsync(CreateAdviseSupervisoryCommand("supervisory-before-unadvise", 54, 507));
|
||||
|
||||
MxCommandReply reply = await session.DispatchAsync(CreateUnAdviseCommand(
|
||||
"unadvise",
|
||||
54,
|
||||
507));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.Equal(54, fakeComObject.UnAdviseServerHandle);
|
||||
Assert.Equal(507, fakeComObject.UnAdvisedItemHandle);
|
||||
Assert.Equal(runtime.StaThreadId, fakeComObject.UnAdviseThreadId);
|
||||
Assert.Empty(await session.GetRegisteredAdviceHandlesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_AdviseWhenMxAccessThrows_PreservesHResultAndDoesNotTrackAdvice()
|
||||
{
|
||||
const int hresult = unchecked((int)0x80070057);
|
||||
FakeMxAccessComObject fakeComObject = new(
|
||||
registerHandle: 55,
|
||||
addItemHandle: 508,
|
||||
adviseException: new COMException("Invalid item handle.", hresult));
|
||||
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
||||
await session.StartAsync(workerProcessId: 1234);
|
||||
await session.DispatchAsync(CreateRegisterCommand("register-before-advise-failure", "client-a"));
|
||||
await session.DispatchAsync(CreateAddItemCommand("add-before-advise-failure", 55, "Galaxy.Tag.Value"));
|
||||
|
||||
MxCommandReply reply = await session.DispatchAsync(CreateAdviseCommand(
|
||||
"advise-failure",
|
||||
55,
|
||||
999));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
|
||||
Assert.True(reply.HasHresult);
|
||||
Assert.Equal(hresult, reply.Hresult);
|
||||
Assert.Contains("0x80070057", reply.DiagnosticMessage);
|
||||
Assert.Equal(55, fakeComObject.AdviseServerHandle);
|
||||
Assert.Equal(999, fakeComObject.AdvisedItemHandle);
|
||||
Assert.Empty(await session.GetRegisteredAdviceHandlesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_UnAdviseWhenMxAccessThrows_PreservesHResultAndKeepsTrackedAdvice()
|
||||
{
|
||||
const int hresult = unchecked((int)0x80070057);
|
||||
FakeMxAccessComObject fakeComObject = new(
|
||||
registerHandle: 56,
|
||||
addItemHandle: 509,
|
||||
unAdviseException: new COMException("Invalid item handle.", hresult));
|
||||
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
||||
await session.StartAsync(workerProcessId: 1234);
|
||||
await session.DispatchAsync(CreateRegisterCommand("register-before-unadvise-failure", "client-a"));
|
||||
await session.DispatchAsync(CreateAddItemCommand("add-before-unadvise-failure", 56, "Galaxy.Tag.Value"));
|
||||
await session.DispatchAsync(CreateAdviseCommand("advise-before-unadvise-failure", 56, 509));
|
||||
|
||||
MxCommandReply reply = await session.DispatchAsync(CreateUnAdviseCommand(
|
||||
"unadvise-failure",
|
||||
56,
|
||||
509));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
|
||||
Assert.True(reply.HasHresult);
|
||||
Assert.Equal(hresult, reply.Hresult);
|
||||
Assert.Contains("0x80070057", reply.DiagnosticMessage);
|
||||
Assert.Equal(56, fakeComObject.UnAdviseServerHandle);
|
||||
Assert.Equal(509, fakeComObject.UnAdvisedItemHandle);
|
||||
|
||||
RegisteredAdviceHandle adviceHandle = Assert.Single(
|
||||
await session.GetRegisteredAdviceHandlesAsync());
|
||||
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]
|
||||
public async Task DispatchAsync_RegisterWithoutPayload_ReturnsInvalidRequest()
|
||||
{
|
||||
@@ -278,6 +507,26 @@ public sealed class MxAccessCommandExecutorTests
|
||||
Assert.Null(factory.FakeComObject.AddItemDefinition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DispatchAsync_AdviseWithoutPayload_ReturnsInvalidRequest()
|
||||
{
|
||||
FakeMxAccessComObjectFactory factory = new(new FakeMxAccessComObject(registerHandle: 57));
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
||||
await session.StartAsync(workerProcessId: 1234);
|
||||
|
||||
MxCommandReply reply = await session.DispatchAsync(new StaCommand(
|
||||
"session-1",
|
||||
"missing-advise-payload",
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Advise,
|
||||
}));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
|
||||
Assert.Null(factory.FakeComObject.AdviseServerHandle);
|
||||
}
|
||||
|
||||
private static StaCommand CreateRegisterCommand(
|
||||
string correlationId,
|
||||
string clientName)
|
||||
@@ -371,6 +620,63 @@ public sealed class MxAccessCommandExecutorTests
|
||||
});
|
||||
}
|
||||
|
||||
private static StaCommand CreateAdviseCommand(
|
||||
string correlationId,
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
return new StaCommand(
|
||||
"session-1",
|
||||
correlationId,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Advise,
|
||||
Advise = new AdviseCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static StaCommand CreateUnAdviseCommand(
|
||||
string correlationId,
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
return new StaCommand(
|
||||
"session-1",
|
||||
correlationId,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.UnAdvise,
|
||||
UnAdvise = new UnAdviseCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static StaCommand CreateAdviseSupervisoryCommand(
|
||||
string correlationId,
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
return new StaCommand(
|
||||
"session-1",
|
||||
correlationId,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AdviseSupervisory,
|
||||
AdviseSupervisory = new AdviseSupervisoryCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static StaRuntime CreateRuntime()
|
||||
{
|
||||
return new StaRuntime(
|
||||
@@ -388,6 +694,10 @@ public sealed class MxAccessCommandExecutorTests
|
||||
private readonly Exception? addItemException;
|
||||
private readonly Exception? addItem2Exception;
|
||||
private readonly Exception? removeItemException;
|
||||
private readonly Exception? adviseException;
|
||||
private readonly Exception? unAdviseException;
|
||||
private readonly Exception? adviseSupervisoryException;
|
||||
private readonly List<string> operationNames = new();
|
||||
|
||||
public FakeMxAccessComObject(
|
||||
int registerHandle,
|
||||
@@ -396,7 +706,10 @@ public sealed class MxAccessCommandExecutorTests
|
||||
Exception? unregisterException = null,
|
||||
Exception? addItemException = null,
|
||||
Exception? addItem2Exception = null,
|
||||
Exception? removeItemException = null)
|
||||
Exception? removeItemException = null,
|
||||
Exception? adviseException = null,
|
||||
Exception? unAdviseException = null,
|
||||
Exception? adviseSupervisoryException = null)
|
||||
{
|
||||
this.registerHandle = registerHandle;
|
||||
this.addItemHandle = addItemHandle;
|
||||
@@ -405,6 +718,9 @@ public sealed class MxAccessCommandExecutorTests
|
||||
this.addItemException = addItemException;
|
||||
this.addItem2Exception = addItem2Exception;
|
||||
this.removeItemException = removeItemException;
|
||||
this.adviseException = adviseException;
|
||||
this.unAdviseException = unAdviseException;
|
||||
this.adviseSupervisoryException = adviseSupervisoryException;
|
||||
}
|
||||
|
||||
public string? RegisteredClientName { get; private set; }
|
||||
@@ -435,8 +751,29 @@ public sealed class MxAccessCommandExecutorTests
|
||||
|
||||
public int? RemoveItemThreadId { get; private set; }
|
||||
|
||||
public int? AdviseServerHandle { get; private set; }
|
||||
|
||||
public int? AdvisedItemHandle { get; private set; }
|
||||
|
||||
public int? AdviseThreadId { get; private set; }
|
||||
|
||||
public int? UnAdviseServerHandle { get; private set; }
|
||||
|
||||
public int? UnAdvisedItemHandle { get; private set; }
|
||||
|
||||
public int? UnAdviseThreadId { get; private set; }
|
||||
|
||||
public int? AdviseSupervisoryServerHandle { get; private set; }
|
||||
|
||||
public int? AdviseSupervisoryItemHandle { get; private set; }
|
||||
|
||||
public int? AdviseSupervisoryThreadId { get; private set; }
|
||||
|
||||
public IReadOnlyList<string> OperationNames => operationNames.ToArray();
|
||||
|
||||
public int Register(string clientName)
|
||||
{
|
||||
operationNames.Add($"Register:{clientName}");
|
||||
RegisteredClientName = clientName;
|
||||
RegisterThreadId = Environment.CurrentManagedThreadId;
|
||||
|
||||
@@ -445,6 +782,7 @@ public sealed class MxAccessCommandExecutorTests
|
||||
|
||||
public void Unregister(int serverHandle)
|
||||
{
|
||||
operationNames.Add($"Unregister:{serverHandle}");
|
||||
UnregisteredServerHandle = serverHandle;
|
||||
UnregisterThreadId = Environment.CurrentManagedThreadId;
|
||||
|
||||
@@ -458,6 +796,7 @@ public sealed class MxAccessCommandExecutorTests
|
||||
int serverHandle,
|
||||
string itemDefinition)
|
||||
{
|
||||
operationNames.Add($"AddItem:{serverHandle}:{itemDefinition}");
|
||||
AddItemServerHandle = serverHandle;
|
||||
AddItemDefinition = itemDefinition;
|
||||
AddItemThreadId = Environment.CurrentManagedThreadId;
|
||||
@@ -475,6 +814,7 @@ public sealed class MxAccessCommandExecutorTests
|
||||
string itemDefinition,
|
||||
string itemContext)
|
||||
{
|
||||
operationNames.Add($"AddItem2:{serverHandle}:{itemDefinition}:{itemContext}");
|
||||
AddItem2ServerHandle = serverHandle;
|
||||
AddItem2Definition = itemDefinition;
|
||||
AddItem2Context = itemContext;
|
||||
@@ -492,6 +832,7 @@ public sealed class MxAccessCommandExecutorTests
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
operationNames.Add($"RemoveItem:{serverHandle}:{itemHandle}");
|
||||
RemoveItemServerHandle = serverHandle;
|
||||
RemovedItemHandle = itemHandle;
|
||||
RemoveItemThreadId = Environment.CurrentManagedThreadId;
|
||||
@@ -501,6 +842,51 @@ public sealed class MxAccessCommandExecutorTests
|
||||
throw removeItemException;
|
||||
}
|
||||
}
|
||||
|
||||
public void Advise(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
operationNames.Add($"Advise:{serverHandle}:{itemHandle}");
|
||||
AdviseServerHandle = serverHandle;
|
||||
AdvisedItemHandle = itemHandle;
|
||||
AdviseThreadId = Environment.CurrentManagedThreadId;
|
||||
|
||||
if (adviseException is not null)
|
||||
{
|
||||
throw adviseException;
|
||||
}
|
||||
}
|
||||
|
||||
public void UnAdvise(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
operationNames.Add($"UnAdvise:{serverHandle}:{itemHandle}");
|
||||
UnAdviseServerHandle = serverHandle;
|
||||
UnAdvisedItemHandle = itemHandle;
|
||||
UnAdviseThreadId = Environment.CurrentManagedThreadId;
|
||||
|
||||
if (unAdviseException is not null)
|
||||
{
|
||||
throw unAdviseException;
|
||||
}
|
||||
}
|
||||
|
||||
public void AdviseSupervisory(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
operationNames.Add($"AdviseSupervisory:{serverHandle}:{itemHandle}");
|
||||
AdviseSupervisoryServerHandle = serverHandle;
|
||||
AdviseSupervisoryItemHandle = itemHandle;
|
||||
AdviseSupervisoryThreadId = Environment.CurrentManagedThreadId;
|
||||
|
||||
if (adviseSupervisoryException is not null)
|
||||
{
|
||||
throw adviseSupervisoryException;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeMxAccessComObjectFactory : IMxAccessComObjectFactory
|
||||
@@ -520,7 +906,9 @@ public sealed class MxAccessCommandExecutorTests
|
||||
|
||||
private sealed class NoopEventSink : IMxAccessEventSink
|
||||
{
|
||||
public void Attach(object mxAccessComObject)
|
||||
public void Attach(
|
||||
object mxAccessComObject,
|
||||
string sessionId)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.MxAccess;
|
||||
|
||||
namespace MxGateway.Worker.Tests.MxAccess;
|
||||
|
||||
public sealed class MxAccessEventMapperTests
|
||||
{
|
||||
private readonly MxAccessEventMapper mapper = new();
|
||||
|
||||
[Fact]
|
||||
public void CreateOnDataChange_ConvertsValueTimestampQualityAndStatuses()
|
||||
{
|
||||
DateTime timestamp = new(2026, 4, 26, 12, 30, 0, DateTimeKind.Utc);
|
||||
FakeStatus[] statuses =
|
||||
{
|
||||
new()
|
||||
{
|
||||
success = -1,
|
||||
category = 0,
|
||||
detectedBy = 5,
|
||||
detail = 0,
|
||||
},
|
||||
};
|
||||
|
||||
MxEvent mxEvent = mapper.CreateOnDataChange(
|
||||
"session-1",
|
||||
serverHandle: 12,
|
||||
itemHandle: 34,
|
||||
value: 42,
|
||||
quality: 192,
|
||||
timestamp: timestamp,
|
||||
statuses: statuses);
|
||||
|
||||
Assert.Equal(MxEventFamily.OnDataChange, mxEvent.Family);
|
||||
Assert.Equal("session-1", mxEvent.SessionId);
|
||||
Assert.Equal(12, mxEvent.ServerHandle);
|
||||
Assert.Equal(34, mxEvent.ItemHandle);
|
||||
Assert.Equal(42, mxEvent.Value.Int32Value);
|
||||
Assert.Equal(192, mxEvent.Quality);
|
||||
Assert.Equal(timestamp, mxEvent.SourceTimestamp.ToDateTime());
|
||||
Assert.Equal(MxEvent.BodyOneofCase.OnDataChange, mxEvent.BodyCase);
|
||||
|
||||
MxStatusProxy status = Assert.Single(mxEvent.Statuses);
|
||||
Assert.Equal(-1, status.Success);
|
||||
Assert.Equal(MxStatusCategory.Ok, status.Category);
|
||||
Assert.Equal(MxStatusSource.RespondingAutomationObject, status.DetectedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateOnWriteCompleteAndOperationComplete_PreservesDistinctFamilies()
|
||||
{
|
||||
MxEvent writeComplete = mapper.CreateOnWriteComplete(
|
||||
"session-1",
|
||||
serverHandle: 1,
|
||||
itemHandle: 2,
|
||||
statuses: Array.Empty<FakeStatus>());
|
||||
MxEvent operationComplete = mapper.CreateOperationComplete(
|
||||
"session-1",
|
||||
serverHandle: 1,
|
||||
itemHandle: 2,
|
||||
statuses: Array.Empty<FakeStatus>());
|
||||
|
||||
Assert.Equal(MxEventFamily.OnWriteComplete, writeComplete.Family);
|
||||
Assert.Equal(MxEvent.BodyOneofCase.OnWriteComplete, writeComplete.BodyCase);
|
||||
Assert.Equal(MxEventFamily.OperationComplete, operationComplete.Family);
|
||||
Assert.Equal(MxEvent.BodyOneofCase.OperationComplete, operationComplete.BodyCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateOnBufferedDataChange_PreservesRawDataTypeAndArrayMetadata()
|
||||
{
|
||||
DateTime firstTimestamp = new(2026, 4, 26, 13, 0, 0, DateTimeKind.Utc);
|
||||
DateTime secondTimestamp = new(2026, 4, 26, 13, 1, 0, DateTimeKind.Utc);
|
||||
|
||||
MxEvent mxEvent = mapper.CreateOnBufferedDataChange(
|
||||
"session-1",
|
||||
serverHandle: 10,
|
||||
itemHandle: 20,
|
||||
rawDataType: 2,
|
||||
value: new[] { 7, 8 },
|
||||
quality: new[] { 192, 0 },
|
||||
timestamp: new[] { firstTimestamp, secondTimestamp },
|
||||
statuses: null);
|
||||
|
||||
Assert.Equal(MxEventFamily.OnBufferedDataChange, mxEvent.Family);
|
||||
Assert.Equal(MxDataType.Integer, mxEvent.OnBufferedDataChange.DataType);
|
||||
Assert.Equal(2, mxEvent.OnBufferedDataChange.RawDataType);
|
||||
Assert.Equal(MxDataType.Integer, mxEvent.Value.ArrayValue.ElementDataType);
|
||||
Assert.Equal(new[] { 7, 8 }, mxEvent.Value.ArrayValue.Int32Values.Values);
|
||||
Assert.Equal(new[] { 192, 0 }, mxEvent.OnBufferedDataChange.QualityValues.Int32Values.Values);
|
||||
Assert.Equal(2, mxEvent.OnBufferedDataChange.TimestampValues.TimestampValues.Values.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1, MxDataType.Unknown)]
|
||||
[InlineData(0, MxDataType.NoData)]
|
||||
[InlineData(1, MxDataType.Boolean)]
|
||||
[InlineData(2, MxDataType.Integer)]
|
||||
[InlineData(6, MxDataType.Time)]
|
||||
[InlineData(15, MxDataType.InternationalizedString)]
|
||||
[InlineData(999, MxDataType.Unknown)]
|
||||
public void MapMxDataType_MapsInstalledMxAccessValues(
|
||||
int rawDataType,
|
||||
MxDataType expectedDataType)
|
||||
{
|
||||
Assert.Equal(expectedDataType, MxAccessEventMapper.MapMxDataType(rawDataType));
|
||||
}
|
||||
|
||||
private sealed class FakeStatus
|
||||
{
|
||||
public int success;
|
||||
public int category;
|
||||
public int detectedBy;
|
||||
public int detail;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.MxAccess;
|
||||
|
||||
namespace MxGateway.Worker.Tests.MxAccess;
|
||||
|
||||
public sealed class MxAccessEventQueueTests
|
||||
{
|
||||
[Fact]
|
||||
public void Enqueue_AssignsMonotonicWorkerSequencesAndPreservesOrder()
|
||||
{
|
||||
MxAccessEventQueue queue = new(capacity: 4);
|
||||
|
||||
WorkerEvent first = queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10));
|
||||
WorkerEvent second = queue.Enqueue(CreateEvent(MxEventFamily.OnWriteComplete, itemHandle: 11));
|
||||
|
||||
Assert.Equal(1UL, first.Event.WorkerSequence);
|
||||
Assert.Equal(2UL, second.Event.WorkerSequence);
|
||||
Assert.NotNull(first.Event.WorkerTimestamp);
|
||||
Assert.Equal(2, queue.Count);
|
||||
Assert.Equal(2UL, queue.LastEventSequence);
|
||||
|
||||
Assert.True(queue.TryDequeue(out WorkerEvent? dequeuedFirst));
|
||||
Assert.True(queue.TryDequeue(out WorkerEvent? dequeuedSecond));
|
||||
Assert.Equal(10, dequeuedFirst?.Event.ItemHandle);
|
||||
Assert.Equal(11, dequeuedSecond?.Event.ItemHandle);
|
||||
Assert.False(queue.TryDequeue(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drain_RemovesAtMostRequestedEvents()
|
||||
{
|
||||
MxAccessEventQueue queue = new(capacity: 4);
|
||||
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10));
|
||||
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 11));
|
||||
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 12));
|
||||
|
||||
IReadOnlyList<WorkerEvent> drained = queue.Drain(maxEvents: 2);
|
||||
|
||||
Assert.Equal(2, drained.Count);
|
||||
Assert.Equal(10, drained[0].Event.ItemHandle);
|
||||
Assert.Equal(11, drained[1].Event.ItemHandle);
|
||||
Assert.Equal(1, queue.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enqueue_WhenCapacityIsExceeded_RecordsOverflowFaultAndRejectsNewEvents()
|
||||
{
|
||||
MxAccessEventQueue queue = new(capacity: 1);
|
||||
queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 10));
|
||||
|
||||
MxAccessEventQueueOverflowException overflow = Assert.Throws<MxAccessEventQueueOverflowException>(
|
||||
() => queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 11)));
|
||||
|
||||
Assert.Equal(1, overflow.Capacity);
|
||||
Assert.True(queue.IsFaulted);
|
||||
Assert.Equal(WorkerFaultCategory.QueueOverflow, queue.Fault?.Category);
|
||||
Assert.Equal(ProtocolStatusCode.WorkerUnavailable, queue.Fault?.ProtocolStatus.Code);
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => queue.Enqueue(CreateEvent(MxEventFamily.OnDataChange, itemHandle: 12)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordFault_KeepsFirstFault()
|
||||
{
|
||||
MxAccessEventQueue queue = new(capacity: 1);
|
||||
queue.RecordFault(new WorkerFault
|
||||
{
|
||||
Category = WorkerFaultCategory.MxaccessEventConversionFailed,
|
||||
});
|
||||
queue.RecordFault(new WorkerFault
|
||||
{
|
||||
Category = WorkerFaultCategory.QueueOverflow,
|
||||
});
|
||||
|
||||
Assert.True(queue.IsFaulted);
|
||||
Assert.Equal(WorkerFaultCategory.MxaccessEventConversionFailed, queue.Fault?.Category);
|
||||
}
|
||||
|
||||
private static MxEvent CreateEvent(
|
||||
MxEventFamily family,
|
||||
int itemHandle)
|
||||
{
|
||||
MxEvent mxEvent = new()
|
||||
{
|
||||
Family = family,
|
||||
SessionId = "session-1",
|
||||
ServerHandle = 1,
|
||||
ItemHandle = itemHandle,
|
||||
};
|
||||
|
||||
switch (family)
|
||||
{
|
||||
case MxEventFamily.OnWriteComplete:
|
||||
mxEvent.OnWriteComplete = new OnWriteCompleteEvent();
|
||||
break;
|
||||
|
||||
default:
|
||||
mxEvent.OnDataChange = new OnDataChangeEvent();
|
||||
break;
|
||||
}
|
||||
|
||||
return mxEvent;
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,127 @@ public sealed class MxAccessLiveComCreationTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdviseAndUnAdvise_WhenOptedIn_RoundTripsInstalledMxAccessSubscription()
|
||||
{
|
||||
if (!RunLiveMxAccessTests())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using MxAccessStaSession session = new();
|
||||
await session.StartAsync(workerProcessId: 1234);
|
||||
|
||||
MxCommandReply registerReply = await RegisterLiveSessionAsync(session, "live-advise-register");
|
||||
int serverHandle = registerReply.Register.ServerHandle;
|
||||
int itemHandle = 0;
|
||||
bool advised = false;
|
||||
|
||||
try
|
||||
{
|
||||
MxCommandReply addItemReply = await session.DispatchAsync(new StaCommand(
|
||||
"session-1",
|
||||
"live-advise-add-item",
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AddItem,
|
||||
AddItem = new AddItemCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemDefinition = GetLiveAddItemReference(),
|
||||
},
|
||||
}));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code);
|
||||
Assert.True(addItemReply.AddItem.ItemHandle > 0);
|
||||
itemHandle = addItemReply.AddItem.ItemHandle;
|
||||
|
||||
MxCommandReply adviseReply = await session.DispatchAsync(new StaCommand(
|
||||
"session-1",
|
||||
"live-advise",
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Advise,
|
||||
Advise = new AdviseCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
},
|
||||
}));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, adviseReply.ProtocolStatus.Code);
|
||||
advised = true;
|
||||
|
||||
MxCommandReply unAdviseReply = await session.DispatchAsync(new StaCommand(
|
||||
"session-1",
|
||||
"live-unadvise",
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.UnAdvise,
|
||||
UnAdvise = new UnAdviseCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
},
|
||||
}));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, unAdviseReply.ProtocolStatus.Code);
|
||||
advised = false;
|
||||
|
||||
MxCommandReply removeItemReply = await session.DispatchAsync(new StaCommand(
|
||||
"session-1",
|
||||
"live-advise-remove-item",
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.RemoveItem,
|
||||
RemoveItem = new RemoveItemCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
},
|
||||
}));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, removeItemReply.ProtocolStatus.Code);
|
||||
itemHandle = 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (advised && itemHandle > 0)
|
||||
{
|
||||
await session.DispatchAsync(new StaCommand(
|
||||
"session-1",
|
||||
"live-unadvise-cleanup",
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.UnAdvise,
|
||||
UnAdvise = new UnAdviseCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (itemHandle > 0)
|
||||
{
|
||||
await session.DispatchAsync(new StaCommand(
|
||||
"session-1",
|
||||
"live-advise-remove-item-cleanup",
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.RemoveItem,
|
||||
RemoveItem = new RemoveItemCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
await UnregisterLiveSessionAsync(session, serverHandle, "live-advise-unregister");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RunLiveMxAccessTests()
|
||||
{
|
||||
return string.Equals(
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class MxAccessStaSessionTests
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(runtime, factory, eventSink);
|
||||
|
||||
WorkerReady ready = await session.StartAsync(workerProcessId: 1234);
|
||||
WorkerReady ready = await session.StartAsync("session-1", workerProcessId: 1234);
|
||||
|
||||
Assert.Equal(1234, ready.WorkerProcessId);
|
||||
Assert.Equal(MxAccessInteropInfo.ProgId, ready.MxaccessProgid);
|
||||
@@ -28,6 +28,7 @@ public sealed class MxAccessStaSessionTests
|
||||
Assert.Equal(runtime.StaThreadId, eventSink.AttachThreadId);
|
||||
Assert.Equal(ApartmentState.STA, factory.CreateApartmentState);
|
||||
Assert.Same(factory.CreatedObject, eventSink.AttachedObject);
|
||||
Assert.Equal("session-1", eventSink.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -107,10 +108,15 @@ public sealed class MxAccessStaSessionTests
|
||||
|
||||
public int? DetachThreadId { get; private set; }
|
||||
|
||||
public void Attach(object mxAccessComObject)
|
||||
public string? SessionId { get; private set; }
|
||||
|
||||
public void Attach(
|
||||
object mxAccessComObject,
|
||||
string sessionId)
|
||||
{
|
||||
AttachedObject = mxAccessComObject;
|
||||
AttachThreadId = Thread.CurrentThread.ManagedThreadId;
|
||||
SessionId = sessionId;
|
||||
}
|
||||
|
||||
public void Detach()
|
||||
|
||||
@@ -110,6 +110,27 @@ public sealed class StaCommandDispatcherTests
|
||||
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]
|
||||
public async Task PopulateHeartbeat_ReportsCurrentCorrelationAndPendingCount()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -11,13 +12,48 @@ public sealed class WorkerPipeClient : IWorkerPipeClient
|
||||
public const int DefaultConnectTimeoutMilliseconds = 30000;
|
||||
|
||||
private readonly int _connectTimeoutMilliseconds;
|
||||
private readonly Func<Stream, WorkerFrameProtocolOptions, IWorkerLogger?, WorkerPipeSession> _sessionFactory;
|
||||
private readonly IWorkerLogger? _logger;
|
||||
|
||||
public WorkerPipeClient()
|
||||
: this(DefaultConnectTimeoutMilliseconds)
|
||||
: this(null, DefaultConnectTimeoutMilliseconds)
|
||||
{
|
||||
}
|
||||
|
||||
public WorkerPipeClient(IWorkerLogger? logger)
|
||||
: this(logger, DefaultConnectTimeoutMilliseconds)
|
||||
{
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -26,6 +62,8 @@ public sealed class WorkerPipeClient : IWorkerPipeClient
|
||||
"Worker pipe connect timeout must be greater than zero.");
|
||||
}
|
||||
|
||||
_logger = logger;
|
||||
_sessionFactory = sessionFactory ?? throw new ArgumentNullException(nameof(sessionFactory));
|
||||
_connectTimeoutMilliseconds = connectTimeoutMilliseconds;
|
||||
}
|
||||
|
||||
@@ -48,8 +86,8 @@ public sealed class WorkerPipeClient : IWorkerPipeClient
|
||||
|
||||
await ConnectAsync(pipe, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
WorkerPipeSession session = new(pipe, frameOptions);
|
||||
await session.CompleteStartupHandshakeAsync(cancellationToken).ConfigureAwait(false);
|
||||
WorkerPipeSession session = _sessionFactory(pipe, frameOptions, _logger);
|
||||
await session.RunAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private Task ConnectAsync(
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.Bootstrap;
|
||||
using MxGateway.Worker.MxAccess;
|
||||
using MxGateway.Worker.Sta;
|
||||
|
||||
namespace MxGateway.Worker.Ipc;
|
||||
|
||||
@@ -13,19 +16,29 @@ public sealed class WorkerPipeSession
|
||||
{
|
||||
private readonly WorkerFrameProtocolOptions _options;
|
||||
private readonly Func<int> _processIdProvider;
|
||||
private readonly Func<IWorkerRuntimeSession> _runtimeSessionFactory;
|
||||
private readonly WorkerPipeSessionOptions _sessionOptions;
|
||||
private readonly IWorkerLogger? _logger;
|
||||
private readonly WorkerFrameReader _reader;
|
||||
private readonly WorkerFrameWriter _writer;
|
||||
private MxAccessStaSession? _mxAccessStaSession;
|
||||
private IWorkerRuntimeSession? _runtimeSession;
|
||||
private long _nextSequence;
|
||||
private WorkerState _state = WorkerState.Starting;
|
||||
private bool _watchdogFaultSent;
|
||||
private bool _shutdownTimedOut;
|
||||
|
||||
public WorkerPipeSession(
|
||||
Stream stream,
|
||||
WorkerFrameProtocolOptions options)
|
||||
WorkerFrameProtocolOptions options,
|
||||
IWorkerLogger? logger = null)
|
||||
: this(
|
||||
new WorkerFrameReader(stream, options),
|
||||
new WorkerFrameWriter(stream, options),
|
||||
options,
|
||||
() => Process.GetCurrentProcess().Id)
|
||||
() => Process.GetCurrentProcess().Id,
|
||||
new WorkerPipeSessionOptions(),
|
||||
() => new MxAccessStaSession(),
|
||||
logger)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -34,11 +47,56 @@ public sealed class WorkerPipeSession
|
||||
WorkerFrameWriter writer,
|
||||
WorkerFrameProtocolOptions options,
|
||||
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));
|
||||
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_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)
|
||||
@@ -76,11 +134,14 @@ public sealed class WorkerPipeSession
|
||||
try
|
||||
{
|
||||
WorkerEnvelope envelope = await _reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
_state = WorkerState.Handshaking;
|
||||
ValidateGatewayHello(envelope);
|
||||
|
||||
await WriteWorkerHelloAsync(cancellationToken).ConfigureAwait(false);
|
||||
_state = WorkerState.InitializingSta;
|
||||
WorkerReady ready = await initializeMxAccessAsync(cancellationToken).ConfigureAwait(false);
|
||||
await WriteWorkerReadyAsync(ready, cancellationToken).ConfigureAwait(false);
|
||||
_state = WorkerState.Ready;
|
||||
}
|
||||
catch (WorkerFrameProtocolException exception)
|
||||
{
|
||||
@@ -140,6 +201,189 @@ public sealed class WorkerPipeSession
|
||||
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(
|
||||
WorkerFrameProtocolException exception,
|
||||
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)
|
||||
{
|
||||
return CreateBaseEnvelope(hello);
|
||||
@@ -193,6 +456,21 @@ public sealed class WorkerPipeSession
|
||||
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)
|
||||
{
|
||||
WorkerEnvelope envelope = CreateBaseEnvelope();
|
||||
@@ -214,6 +492,28 @@ public sealed class WorkerPipeSession
|
||||
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()
|
||||
{
|
||||
return new WorkerEnvelope
|
||||
@@ -231,21 +531,39 @@ public sealed class WorkerPipeSession
|
||||
|
||||
private async Task<WorkerReady> InitializeMxAccessAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_mxAccessStaSession = new MxAccessStaSession();
|
||||
_runtimeSession = new MxAccessStaSession();
|
||||
try
|
||||
{
|
||||
return await _mxAccessStaSession
|
||||
.StartAsync(_processIdProvider(), cancellationToken)
|
||||
return await _runtimeSession
|
||||
.StartAsync(_options.SessionId, _processIdProvider(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_mxAccessStaSession.Dispose();
|
||||
_mxAccessStaSession = null;
|
||||
_runtimeSession.Dispose();
|
||||
_runtimeSession = null;
|
||||
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()
|
||||
{
|
||||
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)
|
||||
{
|
||||
return new WorkerFault
|
||||
@@ -295,6 +664,50 @@ public sealed class WorkerPipeSession
|
||||
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)
|
||||
{
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ namespace MxGateway.Worker.MxAccess;
|
||||
|
||||
public interface IMxAccessEventSink
|
||||
{
|
||||
void Attach(object mxAccessComObject);
|
||||
void Attach(
|
||||
object mxAccessComObject,
|
||||
string sessionId);
|
||||
|
||||
void Detach();
|
||||
}
|
||||
|
||||
@@ -18,4 +18,16 @@ public interface IMxAccessServer
|
||||
void RemoveItem(
|
||||
int serverHandle,
|
||||
int itemHandle);
|
||||
|
||||
void Advise(
|
||||
int serverHandle,
|
||||
int itemHandle);
|
||||
|
||||
void UnAdvise(
|
||||
int serverHandle,
|
||||
int itemHandle);
|
||||
|
||||
void AdviseSupervisory(
|
||||
int serverHandle,
|
||||
int itemHandle);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MxGateway.Worker.MxAccess;
|
||||
|
||||
public enum MxAccessAdviceKind
|
||||
{
|
||||
Plain = 1,
|
||||
Supervisory = 2,
|
||||
}
|
||||
@@ -1,13 +1,39 @@
|
||||
using System;
|
||||
using ArchestrA.MxAccess;
|
||||
using Proto = MxGateway.Contracts.Proto;
|
||||
|
||||
namespace MxGateway.Worker.MxAccess;
|
||||
|
||||
public sealed class MxAccessBaseEventSink : IMxAccessEventSink
|
||||
{
|
||||
private readonly MxAccessEventMapper eventMapper;
|
||||
private readonly MxAccessEventQueue eventQueue;
|
||||
private LMXProxyServerClass? server;
|
||||
private string sessionId = string.Empty;
|
||||
|
||||
public void Attach(object mxAccessComObject)
|
||||
public MxAccessBaseEventSink()
|
||||
: this(new MxAccessEventQueue())
|
||||
{
|
||||
}
|
||||
|
||||
public MxAccessBaseEventSink(MxAccessEventQueue eventQueue)
|
||||
: this(eventQueue, new MxAccessEventMapper())
|
||||
{
|
||||
}
|
||||
|
||||
public MxAccessBaseEventSink(
|
||||
MxAccessEventQueue eventQueue,
|
||||
MxAccessEventMapper eventMapper)
|
||||
{
|
||||
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
|
||||
this.eventMapper = eventMapper ?? throw new ArgumentNullException(nameof(eventMapper));
|
||||
}
|
||||
|
||||
public void Attach(
|
||||
object mxAccessComObject,
|
||||
string sessionId)
|
||||
{
|
||||
this.sessionId = sessionId ?? string.Empty;
|
||||
server = (LMXProxyServerClass)mxAccessComObject;
|
||||
server.OnDataChange += OnDataChange;
|
||||
server.OnWriteComplete += OnWriteComplete;
|
||||
@@ -27,9 +53,10 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink
|
||||
server.OperationComplete -= OperationComplete;
|
||||
server.OnBufferedDataChange -= OnBufferedDataChange;
|
||||
server = null;
|
||||
sessionId = string.Empty;
|
||||
}
|
||||
|
||||
private static void OnDataChange(
|
||||
private void OnDataChange(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
object pvItemValue,
|
||||
@@ -37,23 +64,44 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink
|
||||
object pftItemTimeStamp,
|
||||
ref MXSTATUS_PROXY[] pVars)
|
||||
{
|
||||
MXSTATUS_PROXY[] statuses = pVars;
|
||||
EnqueueEvent(() => eventMapper.CreateOnDataChange(
|
||||
sessionId,
|
||||
hLMXServerHandle,
|
||||
phItemHandle,
|
||||
pvItemValue,
|
||||
pwItemQuality,
|
||||
pftItemTimeStamp,
|
||||
statuses));
|
||||
}
|
||||
|
||||
private static void OnWriteComplete(
|
||||
private void OnWriteComplete(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
ref MXSTATUS_PROXY[] pVars)
|
||||
{
|
||||
MXSTATUS_PROXY[] statuses = pVars;
|
||||
EnqueueEvent(() => eventMapper.CreateOnWriteComplete(
|
||||
sessionId,
|
||||
hLMXServerHandle,
|
||||
phItemHandle,
|
||||
statuses));
|
||||
}
|
||||
|
||||
private static void OperationComplete(
|
||||
private void OperationComplete(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
ref MXSTATUS_PROXY[] pVars)
|
||||
{
|
||||
MXSTATUS_PROXY[] statuses = pVars;
|
||||
EnqueueEvent(() => eventMapper.CreateOperationComplete(
|
||||
sessionId,
|
||||
hLMXServerHandle,
|
||||
phItemHandle,
|
||||
statuses));
|
||||
}
|
||||
|
||||
private static void OnBufferedDataChange(
|
||||
private void OnBufferedDataChange(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
MxDataType dtDataType,
|
||||
@@ -62,5 +110,42 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink
|
||||
object pftItemTimeStamp,
|
||||
ref MXSTATUS_PROXY[] pVars)
|
||||
{
|
||||
MXSTATUS_PROXY[] statuses = pVars;
|
||||
EnqueueEvent(() => eventMapper.CreateOnBufferedDataChange(
|
||||
sessionId,
|
||||
hLMXServerHandle,
|
||||
phItemHandle,
|
||||
(int)dtDataType,
|
||||
pvItemValue,
|
||||
pwItemQuality,
|
||||
pftItemTimeStamp,
|
||||
statuses));
|
||||
}
|
||||
|
||||
private void EnqueueEvent(Func<Proto.MxEvent> createEvent)
|
||||
{
|
||||
try
|
||||
{
|
||||
eventQueue.Enqueue(createEvent());
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
eventQueue.RecordFault(CreateEventConversionFault(exception));
|
||||
}
|
||||
}
|
||||
|
||||
private Proto.WorkerFault CreateEventConversionFault(Exception exception)
|
||||
{
|
||||
return new Proto.WorkerFault
|
||||
{
|
||||
Category = Proto.WorkerFaultCategory.MxaccessEventConversionFailed,
|
||||
ExceptionType = exception.GetType().FullName ?? string.Empty,
|
||||
DiagnosticMessage = $"{exception.GetType().FullName}: HRESULT 0x{unchecked((uint)exception.HResult):X8}",
|
||||
ProtocolStatus = new Proto.ProtocolStatus
|
||||
{
|
||||
Code = Proto.ProtocolStatusCode.MxaccessFailure,
|
||||
Message = "MXAccess event conversion failed.",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,45 @@ public sealed class MxAccessComServer : IMxAccessServer
|
||||
Invoke(nameof(RemoveItem), serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
public void Advise(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
if (mxAccessComObject is ILMXProxyServer mxAccessServer)
|
||||
{
|
||||
mxAccessServer.Advise(serverHandle, itemHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
Invoke(nameof(Advise), serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
public void UnAdvise(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
if (mxAccessComObject is ILMXProxyServer mxAccessServer)
|
||||
{
|
||||
mxAccessServer.UnAdvise(serverHandle, itemHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
Invoke(nameof(UnAdvise), serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
public void AdviseSupervisory(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
if (mxAccessComObject is ILMXProxyServer4 mxAccessServer)
|
||||
{
|
||||
mxAccessServer.AdviseSupervisory(serverHandle, itemHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
Invoke(nameof(AdviseSupervisory), serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
private object Invoke(
|
||||
string methodName,
|
||||
params object[] arguments)
|
||||
|
||||
@@ -37,6 +37,9 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
||||
MxCommandKind.AddItem => ExecuteAddItem(command),
|
||||
MxCommandKind.AddItem2 => ExecuteAddItem2(command),
|
||||
MxCommandKind.RemoveItem => ExecuteRemoveItem(command),
|
||||
MxCommandKind.Advise => ExecuteAdvise(command),
|
||||
MxCommandKind.UnAdvise => ExecuteUnAdvise(command),
|
||||
MxCommandKind.AdviseSupervisory => ExecuteAdviseSupervisory(command),
|
||||
_ => CreateInvalidRequestReply(command, $"Unsupported MXAccess command kind {command.Kind}."),
|
||||
};
|
||||
}
|
||||
@@ -130,6 +133,51 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
||||
return CreateOkReply(command);
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteAdvise(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Advise)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "Advise command payload is required.");
|
||||
}
|
||||
|
||||
AdviseCommand adviseCommand = command.Command.Advise;
|
||||
session.Advise(
|
||||
adviseCommand.ServerHandle,
|
||||
adviseCommand.ItemHandle);
|
||||
|
||||
return CreateOkReply(command);
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteUnAdvise(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.UnAdvise)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "UnAdvise command payload is required.");
|
||||
}
|
||||
|
||||
UnAdviseCommand unAdviseCommand = command.Command.UnAdvise;
|
||||
session.UnAdvise(
|
||||
unAdviseCommand.ServerHandle,
|
||||
unAdviseCommand.ItemHandle);
|
||||
|
||||
return CreateOkReply(command);
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteAdviseSupervisory(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AdviseSupervisory)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "AdviseSupervisory command payload is required.");
|
||||
}
|
||||
|
||||
AdviseSupervisoryCommand adviseSupervisoryCommand = command.Command.AdviseSupervisory;
|
||||
session.AdviseSupervisory(
|
||||
adviseSupervisoryCommand.ServerHandle,
|
||||
adviseSupervisoryCommand.ItemHandle);
|
||||
|
||||
return CreateOkReply(command);
|
||||
}
|
||||
|
||||
private static MxCommandReply CreateOkReply(StaCommand command)
|
||||
{
|
||||
return new MxCommandReply
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
using System;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.Conversion;
|
||||
|
||||
namespace MxGateway.Worker.MxAccess;
|
||||
|
||||
public sealed class MxAccessEventMapper
|
||||
{
|
||||
private readonly VariantConverter variantConverter;
|
||||
private readonly MxStatusProxyConverter statusProxyConverter;
|
||||
|
||||
public MxAccessEventMapper()
|
||||
: this(new VariantConverter(), new MxStatusProxyConverter())
|
||||
{
|
||||
}
|
||||
|
||||
public MxAccessEventMapper(
|
||||
VariantConverter variantConverter,
|
||||
MxStatusProxyConverter statusProxyConverter)
|
||||
{
|
||||
this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
|
||||
this.statusProxyConverter = statusProxyConverter ?? throw new ArgumentNullException(nameof(statusProxyConverter));
|
||||
}
|
||||
|
||||
public MxEvent CreateOnDataChange(
|
||||
string sessionId,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
object? value,
|
||||
int quality,
|
||||
object? timestamp,
|
||||
Array? statuses)
|
||||
{
|
||||
MxEvent mxEvent = CreateBaseEvent(
|
||||
MxEventFamily.OnDataChange,
|
||||
sessionId,
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
statuses);
|
||||
mxEvent.Value = variantConverter.Convert(value);
|
||||
mxEvent.Quality = quality;
|
||||
ApplySourceTimestamp(mxEvent, timestamp);
|
||||
mxEvent.OnDataChange = new OnDataChangeEvent();
|
||||
|
||||
return mxEvent;
|
||||
}
|
||||
|
||||
public MxEvent CreateOnWriteComplete(
|
||||
string sessionId,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
Array? statuses)
|
||||
{
|
||||
MxEvent mxEvent = CreateBaseEvent(
|
||||
MxEventFamily.OnWriteComplete,
|
||||
sessionId,
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
statuses);
|
||||
mxEvent.OnWriteComplete = new OnWriteCompleteEvent();
|
||||
|
||||
return mxEvent;
|
||||
}
|
||||
|
||||
public MxEvent CreateOperationComplete(
|
||||
string sessionId,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
Array? statuses)
|
||||
{
|
||||
MxEvent mxEvent = CreateBaseEvent(
|
||||
MxEventFamily.OperationComplete,
|
||||
sessionId,
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
statuses);
|
||||
mxEvent.OperationComplete = new OperationCompleteEvent();
|
||||
|
||||
return mxEvent;
|
||||
}
|
||||
|
||||
public MxEvent CreateOnBufferedDataChange(
|
||||
string sessionId,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
int rawDataType,
|
||||
object? value,
|
||||
object? quality,
|
||||
object? timestamp,
|
||||
Array? statuses)
|
||||
{
|
||||
MxDataType dataType = MapMxDataType(rawDataType);
|
||||
MxEvent mxEvent = CreateBaseEvent(
|
||||
MxEventFamily.OnBufferedDataChange,
|
||||
sessionId,
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
statuses);
|
||||
mxEvent.Value = variantConverter.Convert(value, dataType);
|
||||
mxEvent.OnBufferedDataChange = new OnBufferedDataChangeEvent
|
||||
{
|
||||
DataType = dataType,
|
||||
RawDataType = rawDataType,
|
||||
QualityValues = ConvertBufferedArray(quality, MxDataType.Integer),
|
||||
TimestampValues = ConvertBufferedArray(timestamp, MxDataType.Time),
|
||||
};
|
||||
|
||||
return mxEvent;
|
||||
}
|
||||
|
||||
public static MxDataType MapMxDataType(int rawDataType)
|
||||
{
|
||||
return rawDataType switch
|
||||
{
|
||||
-1 => MxDataType.Unknown,
|
||||
0 => MxDataType.NoData,
|
||||
1 => MxDataType.Boolean,
|
||||
2 => MxDataType.Integer,
|
||||
3 => MxDataType.Float,
|
||||
4 => MxDataType.Double,
|
||||
5 => MxDataType.String,
|
||||
6 => MxDataType.Time,
|
||||
7 => MxDataType.ElapsedTime,
|
||||
8 => MxDataType.ReferenceType,
|
||||
9 => MxDataType.StatusType,
|
||||
10 => MxDataType.Enum,
|
||||
11 => MxDataType.SecurityClassificationEnum,
|
||||
12 => MxDataType.DataQualityType,
|
||||
13 => MxDataType.QualifiedEnum,
|
||||
14 => MxDataType.QualifiedStruct,
|
||||
15 => MxDataType.InternationalizedString,
|
||||
16 => MxDataType.BigString,
|
||||
17 => MxDataType.End,
|
||||
_ => MxDataType.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
private MxEvent CreateBaseEvent(
|
||||
MxEventFamily family,
|
||||
string sessionId,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
Array? statuses)
|
||||
{
|
||||
MxEvent mxEvent = new()
|
||||
{
|
||||
Family = family,
|
||||
SessionId = sessionId ?? string.Empty,
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
};
|
||||
mxEvent.Statuses.Add(statusProxyConverter.ConvertMany(statuses));
|
||||
|
||||
return mxEvent;
|
||||
}
|
||||
|
||||
private void ApplySourceTimestamp(
|
||||
MxEvent mxEvent,
|
||||
object? timestamp)
|
||||
{
|
||||
MxValue convertedTimestamp = variantConverter.Convert(timestamp, MxDataType.Time);
|
||||
if (convertedTimestamp.KindCase == MxValue.KindOneofCase.TimestampValue)
|
||||
{
|
||||
mxEvent.SourceTimestamp = convertedTimestamp.TimestampValue;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(convertedTimestamp.RawDiagnostic))
|
||||
{
|
||||
mxEvent.RawStatus = string.IsNullOrWhiteSpace(mxEvent.RawStatus)
|
||||
? convertedTimestamp.RawDiagnostic
|
||||
: $"{mxEvent.RawStatus}; {convertedTimestamp.RawDiagnostic}";
|
||||
}
|
||||
}
|
||||
|
||||
private MxArray ConvertBufferedArray(
|
||||
object? value,
|
||||
MxDataType expectedElementDataType)
|
||||
{
|
||||
if (value is Array array)
|
||||
{
|
||||
return variantConverter.ConvertArray(array, expectedElementDataType);
|
||||
}
|
||||
|
||||
MxValue converted = variantConverter.Convert(value, expectedElementDataType);
|
||||
if (converted.KindCase == MxValue.KindOneofCase.ArrayValue)
|
||||
{
|
||||
return converted.ArrayValue;
|
||||
}
|
||||
|
||||
MxArray mxArray = new()
|
||||
{
|
||||
ElementDataType = converted.DataType,
|
||||
VariantType = converted.VariantType,
|
||||
RawElementDataType = converted.RawDataType,
|
||||
RawDiagnostic = string.IsNullOrWhiteSpace(converted.RawDiagnostic)
|
||||
? "Buffered MXAccess event argument was not a SAFEARRAY."
|
||||
: converted.RawDiagnostic,
|
||||
};
|
||||
|
||||
switch (converted.KindCase)
|
||||
{
|
||||
case MxValue.KindOneofCase.Int32Value:
|
||||
mxArray.Int32Values = new Int32Array();
|
||||
mxArray.Int32Values.Values.Add(converted.Int32Value);
|
||||
break;
|
||||
|
||||
case MxValue.KindOneofCase.Int64Value:
|
||||
mxArray.Int64Values = new Int64Array();
|
||||
mxArray.Int64Values.Values.Add(converted.Int64Value);
|
||||
break;
|
||||
|
||||
case MxValue.KindOneofCase.TimestampValue:
|
||||
mxArray.TimestampValues = new TimestampArray();
|
||||
mxArray.TimestampValues.Values.Add(converted.TimestampValue);
|
||||
break;
|
||||
}
|
||||
|
||||
return mxArray;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto;
|
||||
|
||||
namespace MxGateway.Worker.MxAccess;
|
||||
|
||||
public sealed class MxAccessEventQueue
|
||||
{
|
||||
public const int DefaultCapacity = 10000;
|
||||
|
||||
private readonly int capacity;
|
||||
private readonly Queue<WorkerEvent> events;
|
||||
private readonly object syncRoot = new();
|
||||
private ulong lastEventSequence;
|
||||
private WorkerFault? fault;
|
||||
|
||||
public MxAccessEventQueue()
|
||||
: this(DefaultCapacity)
|
||||
{
|
||||
}
|
||||
|
||||
public MxAccessEventQueue(int capacity)
|
||||
{
|
||||
if (capacity <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(capacity),
|
||||
"MXAccess event queue capacity must be greater than zero.");
|
||||
}
|
||||
|
||||
this.capacity = capacity;
|
||||
events = new Queue<WorkerEvent>(capacity);
|
||||
}
|
||||
|
||||
public int Capacity => capacity;
|
||||
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
return events.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ulong LastEventSequence
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
return lastEventSequence;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsFaulted
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
return fault is not null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public WorkerFault? Fault
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
return fault?.Clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public WorkerEvent Enqueue(MxEvent mxEvent)
|
||||
{
|
||||
if (mxEvent is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(mxEvent));
|
||||
}
|
||||
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (fault is not null)
|
||||
{
|
||||
throw new InvalidOperationException("MXAccess outbound event queue is faulted.");
|
||||
}
|
||||
|
||||
if (events.Count >= capacity)
|
||||
{
|
||||
fault = CreateOverflowFault();
|
||||
throw new MxAccessEventQueueOverflowException(capacity);
|
||||
}
|
||||
|
||||
MxEvent queuedEvent = mxEvent.Clone();
|
||||
queuedEvent.WorkerSequence = ++lastEventSequence;
|
||||
queuedEvent.WorkerTimestamp = Timestamp.FromDateTime(DateTime.UtcNow);
|
||||
|
||||
WorkerEvent workerEvent = new()
|
||||
{
|
||||
Event = queuedEvent,
|
||||
};
|
||||
events.Enqueue(workerEvent);
|
||||
|
||||
return workerEvent.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDequeue(out WorkerEvent? workerEvent)
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (events.Count == 0)
|
||||
{
|
||||
workerEvent = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
workerEvent = events.Dequeue().Clone();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<WorkerEvent> Drain(uint maxEvents)
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
int drainCount = maxEvents == 0
|
||||
? events.Count
|
||||
: Math.Min(events.Count, checked((int)Math.Min(maxEvents, int.MaxValue)));
|
||||
if (drainCount == 0)
|
||||
{
|
||||
return Array.Empty<WorkerEvent>();
|
||||
}
|
||||
|
||||
List<WorkerEvent> drained = new(drainCount);
|
||||
for (int index = 0; index < drainCount; index++)
|
||||
{
|
||||
drained.Add(events.Dequeue().Clone());
|
||||
}
|
||||
|
||||
return drained;
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordFault(WorkerFault workerFault)
|
||||
{
|
||||
if (workerFault is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(workerFault));
|
||||
}
|
||||
|
||||
lock (syncRoot)
|
||||
{
|
||||
fault ??= workerFault.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
private WorkerFault CreateOverflowFault()
|
||||
{
|
||||
string message = $"MXAccess outbound event queue reached capacity {capacity}.";
|
||||
return new WorkerFault
|
||||
{
|
||||
Category = WorkerFaultCategory.QueueOverflow,
|
||||
DiagnosticMessage = message,
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.WorkerUnavailable,
|
||||
Message = message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
|
||||
namespace MxGateway.Worker.MxAccess;
|
||||
|
||||
public sealed class MxAccessEventQueueOverflowException : Exception
|
||||
{
|
||||
public MxAccessEventQueueOverflowException(int capacity)
|
||||
: base($"MXAccess outbound event queue reached its configured capacity of {capacity}.")
|
||||
{
|
||||
Capacity = capacity;
|
||||
}
|
||||
|
||||
public int Capacity { get; }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
@@ -7,6 +8,7 @@ public sealed class MxAccessHandleRegistry
|
||||
{
|
||||
private readonly Dictionary<int, RegisteredServerHandle> serverHandles = new();
|
||||
private readonly Dictionary<long, RegisteredItemHandle> itemHandles = new();
|
||||
private readonly Dictionary<AdviceHandleKey, RegisteredAdviceHandle> adviceHandles = new();
|
||||
|
||||
public IReadOnlyList<RegisteredServerHandle> ServerHandles => serverHandles
|
||||
.Values
|
||||
@@ -19,6 +21,13 @@ public sealed class MxAccessHandleRegistry
|
||||
.ThenBy(handle => handle.ItemHandle)
|
||||
.ToArray();
|
||||
|
||||
public IReadOnlyList<RegisteredAdviceHandle> AdviceHandles => adviceHandles
|
||||
.Values
|
||||
.OrderBy(handle => handle.ServerHandle)
|
||||
.ThenBy(handle => handle.ItemHandle)
|
||||
.ThenBy(handle => handle.AdviceKind)
|
||||
.ToArray();
|
||||
|
||||
public void RegisterServerHandle(
|
||||
int serverHandle,
|
||||
string clientName)
|
||||
@@ -37,6 +46,14 @@ public sealed class MxAccessHandleRegistry
|
||||
{
|
||||
itemHandles.Remove(key);
|
||||
}
|
||||
|
||||
foreach (AdviceHandleKey key in adviceHandles
|
||||
.Where(pair => pair.Value.ServerHandle == serverHandle)
|
||||
.Select(pair => pair.Key)
|
||||
.ToArray())
|
||||
{
|
||||
adviceHandles.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
public bool ContainsServerHandle(int serverHandle)
|
||||
@@ -64,6 +81,7 @@ public sealed class MxAccessHandleRegistry
|
||||
int itemHandle)
|
||||
{
|
||||
itemHandles.Remove(CreateItemKey(serverHandle, itemHandle));
|
||||
RemoveAdviceHandles(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
public bool ContainsItemHandle(
|
||||
@@ -73,10 +91,84 @@ public sealed class MxAccessHandleRegistry
|
||||
return itemHandles.ContainsKey(CreateItemKey(serverHandle, itemHandle));
|
||||
}
|
||||
|
||||
public void RegisterAdviceHandle(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
MxAccessAdviceKind adviceKind)
|
||||
{
|
||||
AdviceHandleKey key = new(serverHandle, itemHandle, adviceKind);
|
||||
adviceHandles[key] = new RegisteredAdviceHandle(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
adviceKind);
|
||||
}
|
||||
|
||||
public void RemoveAdviceHandles(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
foreach (AdviceHandleKey key in adviceHandles
|
||||
.Where(pair => pair.Value.ServerHandle == serverHandle && pair.Value.ItemHandle == itemHandle)
|
||||
.Select(pair => pair.Key)
|
||||
.ToArray())
|
||||
{
|
||||
adviceHandles.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
public bool ContainsAdviceHandle(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
MxAccessAdviceKind adviceKind)
|
||||
{
|
||||
return adviceHandles.ContainsKey(new AdviceHandleKey(serverHandle, itemHandle, adviceKind));
|
||||
}
|
||||
|
||||
private static long CreateItemKey(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
return ((long)serverHandle << 32) | (uint)itemHandle;
|
||||
}
|
||||
|
||||
private readonly struct AdviceHandleKey : IEquatable<AdviceHandleKey>
|
||||
{
|
||||
private readonly int serverHandle;
|
||||
private readonly int itemHandle;
|
||||
private readonly MxAccessAdviceKind adviceKind;
|
||||
|
||||
public AdviceHandleKey(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
MxAccessAdviceKind adviceKind)
|
||||
{
|
||||
this.serverHandle = serverHandle;
|
||||
this.itemHandle = itemHandle;
|
||||
this.adviceKind = adviceKind;
|
||||
}
|
||||
|
||||
public bool Equals(AdviceHandleKey other)
|
||||
{
|
||||
return serverHandle == other.serverHandle
|
||||
&& itemHandle == other.itemHandle
|
||||
&& adviceKind == other.adviceKind;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return obj is AdviceHandleKey other && Equals(other);
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
int hashCode = serverHandle;
|
||||
hashCode = (hashCode * 397) ^ itemHandle;
|
||||
hashCode = (hashCode * 397) ^ (int)adviceKind;
|
||||
|
||||
return hashCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto;
|
||||
@@ -44,7 +45,8 @@ public sealed class MxAccessSession : IDisposable
|
||||
|
||||
public static MxAccessSession Create(
|
||||
IMxAccessComObjectFactory factory,
|
||||
IMxAccessEventSink eventSink)
|
||||
IMxAccessEventSink eventSink,
|
||||
string sessionId)
|
||||
{
|
||||
if (factory is null)
|
||||
{
|
||||
@@ -66,7 +68,7 @@ public sealed class MxAccessSession : IDisposable
|
||||
throw new InvalidOperationException("MXAccess COM factory returned null.");
|
||||
}
|
||||
|
||||
eventSink.Attach(mxAccessComObject);
|
||||
eventSink.Attach(mxAccessComObject, sessionId);
|
||||
|
||||
return new MxAccessSession(
|
||||
mxAccessComObject,
|
||||
@@ -151,6 +153,59 @@ public sealed class MxAccessSession : IDisposable
|
||||
handleRegistry.RemoveItemHandle(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
public void Advise(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
mxAccessServer.Advise(serverHandle, itemHandle);
|
||||
handleRegistry.RegisterAdviceHandle(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
MxAccessAdviceKind.Plain);
|
||||
}
|
||||
|
||||
public void UnAdvise(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
mxAccessServer.UnAdvise(serverHandle, itemHandle);
|
||||
handleRegistry.RemoveAdviceHandles(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
public void AdviseSupervisory(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
mxAccessServer.AdviseSupervisory(serverHandle, itemHandle);
|
||||
handleRegistry.RegisterAdviceHandle(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
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()
|
||||
{
|
||||
if (disposed)
|
||||
@@ -158,11 +213,112 @@ public sealed class MxAccessSession : IDisposable
|
||||
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;
|
||||
|
||||
@@ -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.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MxGateway.Contracts.Proto;
|
||||
@@ -7,10 +8,11 @@ using MxGateway.Worker.Sta;
|
||||
|
||||
namespace MxGateway.Worker.MxAccess;
|
||||
|
||||
public sealed class MxAccessStaSession : IDisposable
|
||||
public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
{
|
||||
private readonly IMxAccessComObjectFactory factory;
|
||||
private readonly IMxAccessEventSink eventSink;
|
||||
private readonly MxAccessEventQueue eventQueue;
|
||||
private readonly StaRuntime staRuntime;
|
||||
private StaCommandDispatcher? commandDispatcher;
|
||||
private MxAccessSession? session;
|
||||
@@ -20,7 +22,7 @@ public sealed class MxAccessStaSession : IDisposable
|
||||
: this(
|
||||
new StaRuntime(),
|
||||
new MxAccessComObjectFactory(),
|
||||
new MxAccessBaseEventSink())
|
||||
new MxAccessEventQueue())
|
||||
{
|
||||
}
|
||||
|
||||
@@ -28,13 +30,41 @@ public sealed class MxAccessStaSession : IDisposable
|
||||
StaRuntime staRuntime,
|
||||
IMxAccessComObjectFactory factory,
|
||||
IMxAccessEventSink eventSink)
|
||||
: this(staRuntime, factory, eventSink, new MxAccessEventQueue())
|
||||
{
|
||||
}
|
||||
|
||||
public MxAccessStaSession(
|
||||
StaRuntime staRuntime,
|
||||
IMxAccessComObjectFactory factory,
|
||||
MxAccessEventQueue eventQueue)
|
||||
: this(staRuntime, factory, new MxAccessBaseEventSink(eventQueue), eventQueue)
|
||||
{
|
||||
}
|
||||
|
||||
public MxAccessStaSession(
|
||||
StaRuntime staRuntime,
|
||||
IMxAccessComObjectFactory factory,
|
||||
IMxAccessEventSink eventSink,
|
||||
MxAccessEventQueue eventQueue)
|
||||
{
|
||||
this.staRuntime = staRuntime ?? throw new ArgumentNullException(nameof(staRuntime));
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
|
||||
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
|
||||
}
|
||||
|
||||
public MxAccessEventQueue EventQueue => eventQueue;
|
||||
|
||||
public Task<WorkerReady> StartAsync(
|
||||
int workerProcessId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return StartAsync(string.Empty, workerProcessId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<WorkerReady> StartAsync(
|
||||
string sessionId,
|
||||
int workerProcessId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -48,7 +78,7 @@ public sealed class MxAccessStaSession : IDisposable
|
||||
throw new InvalidOperationException("MXAccess COM session has already been created.");
|
||||
}
|
||||
|
||||
session = MxAccessSession.Create(factory, eventSink);
|
||||
session = MxAccessSession.Create(factory, eventSink, sessionId);
|
||||
commandDispatcher = new StaCommandDispatcher(
|
||||
staRuntime,
|
||||
new MxAccessCommandExecutor(session));
|
||||
@@ -68,6 +98,35 @@ public sealed class MxAccessStaSession : IDisposable
|
||||
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)
|
||||
{
|
||||
return eventQueue.Drain(maxEvents);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RegisteredServerHandle>> GetRegisteredServerHandlesAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -94,6 +153,74 @@ public sealed class MxAccessStaSession : IDisposable
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RegisteredAdviceHandle>> GetRegisteredAdviceHandlesAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
throw new InvalidOperationException("MXAccess COM session has not been started.");
|
||||
}
|
||||
|
||||
return staRuntime.InvokeAsync(
|
||||
() => session.HandleRegistry.AdviceHandles,
|
||||
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()
|
||||
{
|
||||
if (disposed)
|
||||
@@ -101,7 +228,7 @@ public sealed class MxAccessStaSession : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
commandDispatcher?.RequestShutdown();
|
||||
RequestShutdown();
|
||||
|
||||
if (session is not null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace MxGateway.Worker.MxAccess;
|
||||
|
||||
public sealed class RegisteredAdviceHandle
|
||||
{
|
||||
public RegisteredAdviceHandle(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
MxAccessAdviceKind adviceKind)
|
||||
{
|
||||
ServerHandle = serverHandle;
|
||||
ItemHandle = itemHandle;
|
||||
AdviceKind = adviceKind;
|
||||
}
|
||||
|
||||
public int ServerHandle { get; }
|
||||
|
||||
public int ItemHandle { get; }
|
||||
|
||||
public MxAccessAdviceKind AdviceKind { get; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -91,6 +91,14 @@ public sealed class StaCommandDispatcher
|
||||
lock (gate)
|
||||
{
|
||||
shutdownRequested = true;
|
||||
while (commandQueue.Count > 0)
|
||||
{
|
||||
QueuedStaCommand queuedCommand = commandQueue.Dequeue();
|
||||
queuedCommand.Complete(CreateRejectedReply(
|
||||
queuedCommand.Command,
|
||||
ProtocolStatusCode.WorkerUnavailable,
|
||||
"The STA command dispatcher is shutting down."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ public static class WorkerApplication
|
||||
return Run(
|
||||
args,
|
||||
new EnvironmentVariableWorkerEnvironment(),
|
||||
new WorkerConsoleLogger(Console.Error),
|
||||
new WorkerPipeClient());
|
||||
new WorkerConsoleLogger(Console.Error));
|
||||
}
|
||||
|
||||
public static int Run(
|
||||
@@ -26,7 +25,7 @@ public static class WorkerApplication
|
||||
args,
|
||||
environment,
|
||||
logger,
|
||||
new WorkerPipeClient());
|
||||
new WorkerPipeClient(logger));
|
||||
}
|
||||
|
||||
public static int Run(
|
||||
@@ -84,7 +83,7 @@ public static class WorkerApplication
|
||||
|
||||
pipeClient.RunAsync(options).GetAwaiter().GetResult();
|
||||
|
||||
logger.Information("WorkerPipeHandshakeSucceeded", new Dictionary<string, object?>
|
||||
logger.Information("WorkerPipeSessionCompleted", new Dictionary<string, object?>
|
||||
{
|
||||
["session_id"] = options.SessionId,
|
||||
["pipe_name"] = options.PipeName,
|
||||
|
||||
Reference in New Issue
Block a user