test(historian-client): TCP-ify FakeSidecarServer + client tests
This commit is contained in:
+108
-99
@@ -1,4 +1,5 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
|
||||
|
||||
@@ -7,12 +8,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests;
|
||||
/// <summary>
|
||||
/// In-process fake of the Wonderware historian sidecar. Reuses the client-side framing
|
||||
/// code (which is byte-identical to the real sidecar) so the wire bytes round-trip
|
||||
/// correctly without requiring the .NET 4.8 sidecar binary at test time.
|
||||
/// correctly without requiring the .NET 4.8 sidecar binary at test time. Listens on a
|
||||
/// loopback <see cref="TcpListener"/> and serves one connection at a time, mirroring the
|
||||
/// real sidecar's <c>TcpFrameServer</c> single-active-connection model.
|
||||
/// </summary>
|
||||
internal sealed class FakeSidecarServer : IAsyncDisposable
|
||||
{
|
||||
private readonly string _pipeName;
|
||||
private readonly string _expectedSecret;
|
||||
private readonly TcpListener _listener;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _loop;
|
||||
|
||||
@@ -55,141 +58,146 @@ internal sealed class FakeSidecarServer : IAsyncDisposable
|
||||
/// </summary>
|
||||
public bool StallAfterRequest { get; set; }
|
||||
|
||||
/// <summary>Initializes a new instance of FakeSidecarServer with the specified pipe name and expected secret.</summary>
|
||||
/// <param name="pipeName">The name of the named pipe to use for communication.</param>
|
||||
/// <summary>Initializes a new instance of FakeSidecarServer with the specified expected secret.</summary>
|
||||
/// <param name="expectedSecret">The expected shared secret for handshake validation.</param>
|
||||
public FakeSidecarServer(string pipeName, string expectedSecret)
|
||||
public FakeSidecarServer(string expectedSecret)
|
||||
{
|
||||
_pipeName = pipeName;
|
||||
_expectedSecret = expectedSecret;
|
||||
// Bind synchronously in the ctor so BoundPort is readable before StartAsync returns.
|
||||
_listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
_listener.Start();
|
||||
}
|
||||
|
||||
/// <summary>Gets the named pipe name used for communication.</summary>
|
||||
public string PipeName => _pipeName;
|
||||
/// <summary>Gets the loopback host the listener is bound to.</summary>
|
||||
public string Host => "127.0.0.1";
|
||||
|
||||
/// <summary>Starts the fake sidecar server asynchronously.</summary>
|
||||
/// <summary>Gets the TCP port the listener actually bound (OS-assigned).</summary>
|
||||
public int BoundPort => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
|
||||
/// <summary>Starts the fake sidecar server asynchronously. The listener is already bound (ctor).</summary>
|
||||
public Task StartAsync()
|
||||
{
|
||||
_loop = Task.Run(() => RunAsync(_cts.Token));
|
||||
// Give the listener a moment to start so client connect doesn't race.
|
||||
return Task.Delay(50);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
await using var pipe = new NamedPipeServerStream(
|
||||
_pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1,
|
||||
PipeTransmissionMode.Byte, PipeOptions.Asynchronous,
|
||||
inBufferSize: 64 * 1024, outBufferSize: 64 * 1024);
|
||||
|
||||
try { await pipe.WaitForConnectionAsync(ct).ConfigureAwait(false); }
|
||||
TcpClient tcpClient;
|
||||
try { tcpClient = await _listener.AcceptTcpClientAsync(ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (ObjectDisposedException) { break; }
|
||||
|
||||
try
|
||||
using (tcpClient)
|
||||
{
|
||||
using var reader = new FrameReader(pipe, leaveOpen: true);
|
||||
using var writer = new FrameWriter(pipe, leaveOpen: true);
|
||||
|
||||
// Hello handshake.
|
||||
var first = await reader.ReadFrameAsync(ct).ConfigureAwait(false);
|
||||
if (first is null || first.Value.Kind != MessageKind.Hello) continue;
|
||||
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
|
||||
|
||||
if (!string.Equals(hello.SharedSecret, _expectedSecret, StringComparison.Ordinal))
|
||||
try
|
||||
{
|
||||
await writer.WriteAsync(MessageKind.HelloAck, new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" }, ct);
|
||||
continue;
|
||||
}
|
||||
tcpClient.NoDelay = true;
|
||||
var stream = tcpClient.GetStream();
|
||||
using var reader = new FrameReader(stream, leaveOpen: true);
|
||||
using var writer = new FrameWriter(stream, leaveOpen: true);
|
||||
|
||||
await writer.WriteAsync(MessageKind.HelloAck, new HelloAck { Accepted = true, HostName = "fake-sidecar" }, ct);
|
||||
// Hello handshake.
|
||||
var first = await reader.ReadFrameAsync(ct).ConfigureAwait(false);
|
||||
if (first is null || first.Value.Kind != MessageKind.Hello) continue;
|
||||
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
|
||||
|
||||
if (DisconnectAfterHandshake)
|
||||
{
|
||||
DisconnectAfterHandshake = false; // arm once
|
||||
pipe.Disconnect();
|
||||
continue;
|
||||
}
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var frame = await reader.ReadFrameAsync(ct).ConfigureAwait(false);
|
||||
if (frame is null) break;
|
||||
|
||||
// Drop before sending any reply — lets the client fall into its catch /
|
||||
// retry path or propagate on second failure.
|
||||
if (DisconnectBeforeReply)
|
||||
if (!string.Equals(hello.SharedSecret, _expectedSecret, StringComparison.Ordinal))
|
||||
{
|
||||
pipe.Disconnect();
|
||||
break;
|
||||
}
|
||||
|
||||
// Stall indefinitely to let the client's call-timeout token fire.
|
||||
if (StallAfterRequest)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
|
||||
// Optionally send a deliberately wrong kind back to exercise
|
||||
// InvalidDataException detection in the client's ExchangeAsync.
|
||||
if (ReplyWithWrongKind.HasValue)
|
||||
{
|
||||
var wrongKind = ReplyWithWrongKind.Value;
|
||||
ReplyWithWrongKind = null; // arm once
|
||||
// Send an empty body with the wrong kind so the client can parse it.
|
||||
await writer.WriteAsync(wrongKind, new ReadRawReply { Success = false }, ct);
|
||||
await writer.WriteAsync(MessageKind.HelloAck, new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" }, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (frame.Value.Kind)
|
||||
await writer.WriteAsync(MessageKind.HelloAck, new HelloAck { Accepted = true, HostName = "fake-sidecar" }, ct);
|
||||
|
||||
if (DisconnectAfterHandshake)
|
||||
{
|
||||
case MessageKind.ReadRawRequest:
|
||||
DisconnectAfterHandshake = false; // arm once
|
||||
tcpClient.Close();
|
||||
continue;
|
||||
}
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var frame = await reader.ReadFrameAsync(ct).ConfigureAwait(false);
|
||||
if (frame is null) break;
|
||||
|
||||
// Drop before sending any reply — lets the client fall into its catch /
|
||||
// retry path or propagate on second failure.
|
||||
if (DisconnectBeforeReply)
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ReadRawRequest>(frame.Value.Body);
|
||||
var reply = OnReadRaw(req);
|
||||
reply.CorrelationId = req.CorrelationId;
|
||||
await writer.WriteAsync(MessageKind.ReadRawReply, reply, ct);
|
||||
tcpClient.Close();
|
||||
break;
|
||||
}
|
||||
case MessageKind.ReadProcessedRequest:
|
||||
|
||||
// Stall indefinitely to let the client's call-timeout token fire.
|
||||
if (StallAfterRequest)
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ReadProcessedRequest>(frame.Value.Body);
|
||||
var reply = OnReadProcessed(req);
|
||||
reply.CorrelationId = req.CorrelationId;
|
||||
await writer.WriteAsync(MessageKind.ReadProcessedReply, reply, ct);
|
||||
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
case MessageKind.ReadAtTimeRequest:
|
||||
|
||||
// Optionally send a deliberately wrong kind back to exercise
|
||||
// InvalidDataException detection in the client's ExchangeAsync.
|
||||
if (ReplyWithWrongKind.HasValue)
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ReadAtTimeRequest>(frame.Value.Body);
|
||||
var reply = OnReadAtTime(req);
|
||||
reply.CorrelationId = req.CorrelationId;
|
||||
await writer.WriteAsync(MessageKind.ReadAtTimeReply, reply, ct);
|
||||
break;
|
||||
var wrongKind = ReplyWithWrongKind.Value;
|
||||
ReplyWithWrongKind = null; // arm once
|
||||
// Send an empty body with the wrong kind so the client can parse it.
|
||||
await writer.WriteAsync(wrongKind, new ReadRawReply { Success = false }, ct);
|
||||
continue;
|
||||
}
|
||||
case MessageKind.ReadEventsRequest:
|
||||
|
||||
switch (frame.Value.Kind)
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ReadEventsRequest>(frame.Value.Body);
|
||||
var reply = OnReadEvents(req);
|
||||
reply.CorrelationId = req.CorrelationId;
|
||||
await writer.WriteAsync(MessageKind.ReadEventsReply, reply, ct);
|
||||
break;
|
||||
}
|
||||
case MessageKind.WriteAlarmEventsRequest:
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<WriteAlarmEventsRequest>(frame.Value.Body);
|
||||
var reply = OnWriteAlarmEvents(req);
|
||||
reply.CorrelationId = req.CorrelationId;
|
||||
await writer.WriteAsync(MessageKind.WriteAlarmEventsReply, reply, ct);
|
||||
break;
|
||||
case MessageKind.ReadRawRequest:
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ReadRawRequest>(frame.Value.Body);
|
||||
var reply = OnReadRaw(req);
|
||||
reply.CorrelationId = req.CorrelationId;
|
||||
await writer.WriteAsync(MessageKind.ReadRawReply, reply, ct);
|
||||
break;
|
||||
}
|
||||
case MessageKind.ReadProcessedRequest:
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ReadProcessedRequest>(frame.Value.Body);
|
||||
var reply = OnReadProcessed(req);
|
||||
reply.CorrelationId = req.CorrelationId;
|
||||
await writer.WriteAsync(MessageKind.ReadProcessedReply, reply, ct);
|
||||
break;
|
||||
}
|
||||
case MessageKind.ReadAtTimeRequest:
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ReadAtTimeRequest>(frame.Value.Body);
|
||||
var reply = OnReadAtTime(req);
|
||||
reply.CorrelationId = req.CorrelationId;
|
||||
await writer.WriteAsync(MessageKind.ReadAtTimeReply, reply, ct);
|
||||
break;
|
||||
}
|
||||
case MessageKind.ReadEventsRequest:
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<ReadEventsRequest>(frame.Value.Body);
|
||||
var reply = OnReadEvents(req);
|
||||
reply.CorrelationId = req.CorrelationId;
|
||||
await writer.WriteAsync(MessageKind.ReadEventsReply, reply, ct);
|
||||
break;
|
||||
}
|
||||
case MessageKind.WriteAlarmEventsRequest:
|
||||
{
|
||||
var req = MessagePackSerializer.Deserialize<WriteAlarmEventsRequest>(frame.Value.Body);
|
||||
var reply = OnWriteAlarmEvents(req);
|
||||
reply.CorrelationId = req.CorrelationId;
|
||||
await writer.WriteAsync(MessageKind.WriteAlarmEventsReply, reply, ct);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (IOException) { /* peer dropped — accept next */ }
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (IOException) { /* peer dropped — accept next */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +205,7 @@ internal sealed class FakeSidecarServer : IAsyncDisposable
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_cts.Cancel();
|
||||
try { _listener.Stop(); } catch { /* ignore */ }
|
||||
if (_loop is not null)
|
||||
{
|
||||
try { await _loop.ConfigureAwait(false); } catch { /* ignore shutdown errors */ }
|
||||
|
||||
Reference in New Issue
Block a user