3edff3304b
- Process-wide CipConnection statics (ForwardOpenCount, Forward Close counts, RequestsSent, exchange stats, OpenAttempts, OpenAttemptBudget) move to an instance ConnectionDiagnostics owned by each DeviceSessionPool (and by a stand-alone CipConnection). The open-attempt budget becomes a disposable per-DeviceKey limit (LimitOpenAttempts); the differential harness scopes one to the press under test. - LibplctagOrphanRelease.ForceRootingForTest is gone: ReleaseIfOrphaned(tag, canReadHandles) overload. - A connected reply with general 0x01 / extended 0x0107 or 0x0203 (target no longer holds the class-3 connection, TCP still up) retires the session at once; the next send reopens through the throttle. - New tests: dispose during RegisterSession, abandoned-open observer (falsified), scheduler-dispose-throws guard (DeviceSessionPool.CloseAsync), release-in-background with a shared open in flight, per-device limit scoping, CIP-close recycle (request + keep-alive + negative control). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
409 lines
20 KiB
C#
409 lines
20 KiB
C#
using System.Buffers.Binary;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Eip;
|
|
|
|
/// <summary>
|
|
/// <see cref="EncapsulationClient"/> against <see cref="FakeEipServer"/>. Late-reply scenarios are
|
|
/// driven by a TaskCompletionSource the test releases, not by sleeps, so ordering is deterministic.
|
|
/// </summary>
|
|
public sealed class EncapsulationClientTests
|
|
{
|
|
private static readonly TimeSpan Generous = TimeSpan.FromSeconds(5);
|
|
private static readonly TimeSpan Guard = TimeSpan.FromSeconds(10);
|
|
private static CancellationToken TestCt => TestContext.Current.CancellationToken;
|
|
|
|
private static Task<EncapsulationClient> Connect(FakeEipServer server) =>
|
|
EncapsulationClient.ConnectAsync("127.0.0.1", server.Port, Generous, TestCt);
|
|
|
|
private static bool IsRR(byte[] f) => FakeEipServer.Command(f) == EncapCommand.SendRRData;
|
|
|
|
[Fact]
|
|
public async Task Registers_and_stores_session_handle()
|
|
{
|
|
await using var server = new FakeEipServer();
|
|
await using var client = await Connect(server);
|
|
client.SessionHandle.ShouldBe(FakeEipServer.Session);
|
|
client.IsConnected.ShouldBeTrue();
|
|
FakeEipServer.Command(server.Received[0]).ShouldBe(EncapCommand.RegisterSession);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterSession_goes_on_the_wire_with_a_zero_sender_context()
|
|
{
|
|
// ab_server refuses RegisterSession with any non-zero sender context (status 1, header-only
|
|
// reply), so the client must not stamp its per-request context on it. Found by Task 17.
|
|
await using var server = new FakeEipServer();
|
|
await using var client = await Connect(server);
|
|
EncapsulationCodec.ReadHeader(server.Received[0]).SenderContext.ShouldBe(0UL);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendRRData_returns_cip_payload()
|
|
{
|
|
await using var server = new FakeEipServer();
|
|
byte[] cipReply = [0xCC, 0, 0, 0, 0xC4, 0, 1, 0, 0, 0];
|
|
server.Handler = req => FakeEipServer.RRReply(req, cipReply);
|
|
await using var client = await Connect(server);
|
|
|
|
var result = await client.SendRRDataAsync(new byte[] { 0x4C, 0x02 }, Generous, TestCt);
|
|
|
|
result.ToArray().ShouldBe(cipReply);
|
|
var sent = server.Received.Single(IsRR);
|
|
BinaryPrimitives.ReadUInt32LittleEndian(sent.AsSpan(4)).ShouldBe(FakeEipServer.Session);
|
|
FakeEipServer.RequestCip(sent).ShouldBe(new byte[] { 0x4C, 0x02 });
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Works_against_a_server_that_zeroes_the_reply_sender_context()
|
|
{
|
|
// ab_server behaviour (Task 4 captures): every reply carries context 0 (R5-2, R5-3).
|
|
await using var server = new FakeEipServer { ZeroReplyContext = true };
|
|
server.Handler = req => FakeEipServer.RRReply(req, [0xCC, 0, 0, 0]);
|
|
await using var client = await Connect(server);
|
|
|
|
client.SessionHandle.ShouldBe(FakeEipServer.Session);
|
|
(await client.SendRRDataAsync(new byte[] { 0x4C }, Generous, TestCt)).ToArray().ShouldBe(new byte[] { 0xCC, 0, 0, 0 });
|
|
(await client.SendRRDataAsync(new byte[] { 0x4C }, Generous, TestCt)).ToArray().ShouldBe(new byte[] { 0xCC, 0, 0, 0 });
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Concurrent_sends_are_serialized()
|
|
{
|
|
await using var server = new FakeEipServer();
|
|
server.HandlerAsync = async req =>
|
|
{
|
|
await Task.Delay(20, TestCt);
|
|
return FakeEipServer.RRReply(req, FakeEipServer.RequestCip(req)); // echo
|
|
};
|
|
await using var client = await Connect(server);
|
|
|
|
var calls = Enumerable.Range(1, 5)
|
|
.Select(i => client.SendRRDataAsync(new[] { (byte)i }, Generous, TestCt))
|
|
.ToArray();
|
|
var results = await Task.WhenAll(calls).WaitAsync(Guard, TestCt);
|
|
|
|
// The fake records frames the instant they arrive, independently of its responder, so a client
|
|
// that pipelined a second request before the first reply would push this to 2+.
|
|
server.MaxOutstanding.ShouldBe(1);
|
|
for (var i = 0; i < 5; i++) results[i].ToArray().ShouldBe(new[] { (byte)(i + 1) });
|
|
server.Received.Count(IsRR).ShouldBe(5);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Stalled_reply_times_out_and_closes_the_socket()
|
|
{
|
|
await using var server = new FakeEipServer { Stall = true };
|
|
await using var client = await Connect(server);
|
|
|
|
var ex = await Should.ThrowAsync<AbTransportException>(
|
|
client.SendRRDataAsync(new byte[] { 0x4C }, TimeSpan.FromMilliseconds(200), TestCt).WaitAsync(Guard, TestCt));
|
|
|
|
ex.Status.ShouldBe(AbStatus.Timeout);
|
|
client.IsConnected.ShouldBeFalse();
|
|
(await Should.ThrowAsync<AbTransportException>(client.SendRRDataAsync(new byte[] { 0x4C }, Generous, TestCt)))
|
|
.Status.ShouldBe(AbStatus.BadConnection);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)] // R5-3: the late reply carries a ZEROED context, so context matching cannot reject it
|
|
public async Task Caller_cancellation_does_not_fault_the_connection_and_the_late_reply_is_never_misattributed(bool zeroContext)
|
|
{
|
|
await using var server = new FakeEipServer { ZeroReplyContext = zeroContext };
|
|
var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
byte[] stale = [0xCC, 0, 0, 0, 0xAA];
|
|
byte[] fresh = [0xCC, 0, 0, 0, 0xBB];
|
|
server.ScriptAsync(
|
|
async req => { await releaseFirst.Task; return FakeEipServer.RRReply(req, stale); },
|
|
req => Task.FromResult<byte[]?>(FakeEipServer.RRReply(req, fresh)));
|
|
await using var client = await Connect(server);
|
|
|
|
using var callerCts = new CancellationTokenSource();
|
|
var first = client.SendRRDataAsync(new byte[] { 0x01 }, Generous, callerCts.Token);
|
|
await server.WaitForFrameAsync(IsRR, Guard); // request 1 is on the wire
|
|
await callerCts.CancelAsync();
|
|
|
|
await Should.ThrowAsync<OperationCanceledException>(first.WaitAsync(Guard, TestCt));
|
|
client.IsConnected.ShouldBeTrue();
|
|
|
|
// Request 2 is issued while request 1's reply is still outstanding; only then does the server
|
|
// answer request 1 (late, after the cancel, before the timeout).
|
|
var second = client.SendRRDataAsync(new byte[] { 0x02 }, Generous, TestCt);
|
|
releaseFirst.SetResult();
|
|
|
|
(await second.WaitAsync(Guard, TestCt)).ToArray().ShouldBe(fresh);
|
|
client.IsConnected.ShouldBeTrue();
|
|
server.MaxOutstanding.ShouldBe(1); // request 2 waited for reply 1 (R5-1)
|
|
server.Received.Count(IsRR).ShouldBe(2);
|
|
server.HandlerErrors.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Caller_cancellation_then_no_reply_faults_at_the_timeout_and_the_next_call_does_not_hang()
|
|
{
|
|
await using var server = new FakeEipServer { Stall = true };
|
|
await using var client = await Connect(server);
|
|
|
|
using var callerCts = new CancellationTokenSource();
|
|
var first = client.SendRRDataAsync(new byte[] { 0x01 }, TimeSpan.FromMilliseconds(300), callerCts.Token);
|
|
// Both calls are issued back-to-back, before any await: `first` acquired the gate synchronously
|
|
// and `next` is therefore already queued behind it before the cancel — the ordering does not
|
|
// depend on how long the test thread takes to get around to it.
|
|
var next = client.SendRRDataAsync(new byte[] { 0x02 }, Generous, TestCt);
|
|
await callerCts.CancelAsync();
|
|
await Should.ThrowAsync<OperationCanceledException>(first.WaitAsync(Guard, TestCt));
|
|
await server.WaitForFrameAsync(IsRR, Guard); // the abandoned request was on the wire
|
|
|
|
// Queued behind the draining exchange: must fail fast once that exchange times out, not hang.
|
|
var ex = await Should.ThrowAsync<AbTransportException>(next.WaitAsync(Guard, TestCt));
|
|
|
|
ex.Status.ShouldBe(AbStatus.BadConnection);
|
|
client.IsConnected.ShouldBeFalse();
|
|
server.Received.Count(IsRR).ShouldBe(1); // the queued request was never written
|
|
(await Should.ThrowAsync<AbTransportException>(client.SendRRDataAsync(new byte[] { 0x03 }, Generous, TestCt)
|
|
.WaitAsync(Guard, TestCt)))
|
|
.Status.ShouldBe(AbStatus.BadConnection);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterSession_reply_matches_only_through_the_zero_context_tolerance()
|
|
{
|
|
// Documents a dependency: RegisterSession goes out with sender context 0 (ab_server refuses
|
|
// non-zero) so its reply echoes 0, while the exchange's own context is a non-zero counter value.
|
|
// The only thing that lets the reply match is TryMatch's "reply context == 0" rule; tighten
|
|
// that rule and every Connect breaks, not just the zero-context-server case.
|
|
await using var server = new FakeEipServer();
|
|
await using var client = await Connect(server);
|
|
|
|
var request = EncapsulationCodec.ReadHeader(server.Received[0]);
|
|
request.Command.ShouldBe(EncapCommand.RegisterSession);
|
|
request.SenderContext.ShouldBe(0UL);
|
|
client.SessionHandle.ShouldBe(FakeEipServer.Session);
|
|
client.IsConnected.ShouldBeTrue();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(-5)]
|
|
public async Task Non_positive_timeouts_are_rejected_everywhere(int milliseconds)
|
|
{
|
|
await using var server = new FakeEipServer();
|
|
var bad = TimeSpan.FromMilliseconds(milliseconds);
|
|
await Should.ThrowAsync<ArgumentOutOfRangeException>(
|
|
EncapsulationClient.ConnectAsync("127.0.0.1", server.Port, bad, TestCt));
|
|
await using var client = await Connect(server);
|
|
await Should.ThrowAsync<ArgumentOutOfRangeException>(client.SendRRDataAsync(new byte[] { 0x4C }, bad, TestCt));
|
|
await Should.ThrowAsync<ArgumentOutOfRangeException>(client.SendUnitDataAsync(1, new byte[] { 0x4C }, bad, TestCt));
|
|
client.IsConnected.ShouldBeTrue(); // a bad argument is not a comms fault
|
|
server.Received.Count(IsRR).ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Infinite_timeouts_are_rejected_everywhere()
|
|
{
|
|
await using var server = new FakeEipServer();
|
|
var inf = Timeout.InfiniteTimeSpan;
|
|
await Should.ThrowAsync<ArgumentOutOfRangeException>(
|
|
EncapsulationClient.ConnectAsync("127.0.0.1", server.Port, inf, TestCt));
|
|
await using var client = await Connect(server);
|
|
await Should.ThrowAsync<ArgumentOutOfRangeException>(client.SendRRDataAsync(new byte[] { 0x4C }, inf, TestCt));
|
|
await Should.ThrowAsync<ArgumentOutOfRangeException>(client.SendUnitDataAsync(1, new byte[] { 0x4C }, inf, TestCt));
|
|
client.IsConnected.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Waiting_for_the_gate_is_bounded_by_the_timeout_and_leaves_the_connection_intact()
|
|
{
|
|
await using var server = new FakeEipServer { Stall = true };
|
|
var client = await Connect(server);
|
|
|
|
var holder = client.SendRRDataAsync(new byte[] { 0x01 }, Generous, TestCt); // holds the gate: no reply comes
|
|
await server.WaitForFrameAsync(IsRR, Guard);
|
|
|
|
var ex = await Should.ThrowAsync<AbTransportException>(
|
|
client.SendRRDataAsync(new byte[] { 0x02 }, TimeSpan.FromMilliseconds(150), TestCt).WaitAsync(Guard, TestCt));
|
|
|
|
ex.Status.ShouldBe(AbStatus.Timeout);
|
|
client.IsConnected.ShouldBeTrue(); // the waiter wrote nothing, so nothing desynced
|
|
holder.IsCompleted.ShouldBeFalse(); // positive control: the holder is still waiting on its reply
|
|
server.Received.Count(IsRR).ShouldBe(1);
|
|
|
|
await client.DisposeAsync(); // closes the socket under the holder
|
|
(await Should.ThrowAsync<AbTransportException>(holder.WaitAsync(Guard, TestCt)))
|
|
.Status.ShouldBe(AbStatus.BadConnection);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)] // drain outlives the unregister budget: the socket is closed under it, nothing is written
|
|
[InlineData(true)] // drain times out while Dispose waits for the gate: the session is faulted, nothing is written
|
|
public async Task Dispose_during_a_cancelled_reply_drain_closes_without_interleaving_an_UnRegisterSession(bool drainTimesOutFirst)
|
|
{
|
|
await using var server = new FakeEipServer { Stall = true };
|
|
var client = await Connect(server);
|
|
|
|
using var callerCts = new CancellationTokenSource();
|
|
var first = client.SendRRDataAsync(new byte[] { 0x01 },
|
|
drainTimesOutFirst ? TimeSpan.FromMilliseconds(100) : Generous, callerCts.Token);
|
|
await server.WaitForFrameAsync(IsRR, Guard);
|
|
await callerCts.CancelAsync();
|
|
await Should.ThrowAsync<OperationCanceledException>(first.WaitAsync(Guard, TestCt));
|
|
|
|
await client.DisposeAsync().AsTask().WaitAsync(Guard, TestCt); // must not hang on the drain
|
|
|
|
client.IsConnected.ShouldBeFalse();
|
|
(await Should.ThrowAsync<AbTransportException>(client.SendRRDataAsync(new byte[] { 0x03 }, Generous, TestCt)))
|
|
.Status.ShouldBe(AbStatus.BadConnection);
|
|
|
|
// Absence needs a positive control: a second client's clean dispose MUST put an UnRegisterSession
|
|
// on the wire, so by the time it arrives the first client's (had there been one) would have too.
|
|
var second = await Connect(server);
|
|
await second.DisposeAsync();
|
|
await server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession, Guard);
|
|
server.Received.Count(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession).ShouldBe(1);
|
|
server.Received.Count(IsRR).ShouldBe(1); // and the queued/abandoned work never doubled
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_abandoned_exchange_that_times_out_leaves_no_unobserved_task_exception()
|
|
{
|
|
// Gitea #583: collected against a positive control (a deliberately unobserved fault that MUST be reported),
|
|
// not after a fixed number of GC cycles.
|
|
var unobserved = await UnobservedTaskProbe.CollectAsync(AbandonAnExchangeThatTimesOut, Guard);
|
|
unobserved.ShouldNotContain(x => x.Message.Contains("within 317 ms"));
|
|
}
|
|
|
|
// Kept out of the test body so no local in the test's own state machine roots the abandoned task.
|
|
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
|
|
private static async Task AbandonAnExchangeThatTimesOut()
|
|
{
|
|
await using var server = new FakeEipServer { Stall = true };
|
|
await using var client = await Connect(server);
|
|
using (var callerCts = new CancellationTokenSource())
|
|
{
|
|
var first = client.SendRRDataAsync(new byte[] { 0x01 }, TimeSpan.FromMilliseconds(317), callerCts.Token);
|
|
await server.WaitForFrameAsync(IsRR, Guard);
|
|
await callerCts.CancelAsync();
|
|
await Should.ThrowAsync<OperationCanceledException>(first.WaitAsync(Guard, TestCt));
|
|
}
|
|
var deadline = DateTime.UtcNow + Guard;
|
|
while (client.IsConnected && DateTime.UtcNow < deadline) await Task.Delay(10, TestCt); // drain times out
|
|
client.IsConnected.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Connected_sequence_is_assigned_inside_the_gate()
|
|
{
|
|
await using var server = new FakeEipServer();
|
|
server.Handler = req => FakeEipServer.UnitReply(req, FakeEipServer.RequestCip(req)); // echo seq + cip
|
|
await using var client = await Connect(server);
|
|
|
|
var calls = Enumerable.Range(1, 20)
|
|
.Select(i => client.SendUnitDataAsync(0xAABBCCDD, new[] { (byte)i }, Generous, TestCt))
|
|
.ToArray();
|
|
var results = await Task.WhenAll(calls).WaitAsync(Guard, TestCt);
|
|
|
|
var units = server.Received.Where(f => FakeEipServer.Command(f) == EncapCommand.SendUnitData).ToList();
|
|
var sequences = units.Select(FakeEipServer.RequestSequence).ToList();
|
|
sequences.ShouldBe(Enumerable.Range(1, 20).Select(i => (ushort)i).ToList()); // strictly increasing, no dupes
|
|
units.Select(f => BinaryPrimitives.ReadUInt32LittleEndian(f.AsSpan(36))).ShouldAllBe(id => id == 0xAABBCCDD); // A1 item connection id
|
|
for (var i = 0; i < 20; i++) results[i].ToArray().ShouldBe(new[] { (byte)(i + 1) }); // each got its own reply
|
|
server.MaxOutstanding.ShouldBe(1);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0, 1)]
|
|
[InlineData(1, 2)]
|
|
[InlineData(65534, 65535)]
|
|
[InlineData(65535, 1)] // wraps past 0, which is never put on the wire
|
|
public void Connected_sequence_wraps_65535_to_1(int current, int next) =>
|
|
EncapsulationClient.NextSequence((ushort)current).ShouldBe((ushort)next);
|
|
|
|
[Fact]
|
|
public async Task Reply_with_foreign_context_is_skipped()
|
|
{
|
|
await using var server = new FakeEipServer();
|
|
byte[] good = [0xCC, 0, 0, 0, 0x01];
|
|
server.Handler = req =>
|
|
[.. FakeEipServer.RRReplyWithContext(req, 0xDEADBEEF, [0xCC, 0, 0, 0, 0xEE]), .. FakeEipServer.RRReply(req, good)];
|
|
await using var client = await Connect(server);
|
|
|
|
(await client.SendRRDataAsync(new byte[] { 0x4C }, Generous, TestCt)).ToArray().ShouldBe(good);
|
|
client.IsConnected.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Connected_reply_with_foreign_sequence_is_skipped()
|
|
{
|
|
await using var server = new FakeEipServer();
|
|
byte[] good = [0xCC, 0, 0, 0, 0x01];
|
|
server.Handler = req =>
|
|
[.. FakeEipServer.UnitReplyWithSequence(req, 999, [0xCC, 0, 0, 0, 0xEE]), .. FakeEipServer.UnitReply(req, good)];
|
|
await using var client = await Connect(server);
|
|
|
|
(await client.SendUnitDataAsync(1, new byte[] { 0x4C }, Generous, TestCt)).ToArray().ShouldBe(good);
|
|
client.IsConnected.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Dropped_connection_is_BadConnection_and_faults()
|
|
{
|
|
await using var server = new FakeEipServer { DropOnNextRequest = true };
|
|
await using var client = await Connect(server);
|
|
|
|
(await Should.ThrowAsync<AbTransportException>(
|
|
client.SendRRDataAsync(new byte[] { 0x4C }, Generous, TestCt).WaitAsync(Guard, TestCt)))
|
|
.Status.ShouldBe(AbStatus.BadConnection);
|
|
client.IsConnected.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Nonzero_encapsulation_status_in_the_reply_is_BadReply_and_faults()
|
|
{
|
|
await using var server = new FakeEipServer();
|
|
server.Handler = req =>
|
|
{
|
|
var r = FakeEipServer.RRReply(req, [0xCC, 0, 0, 0]);
|
|
r[8] = 0x64; // status 0x64: invalid session handle
|
|
return r;
|
|
};
|
|
await using var client = await Connect(server);
|
|
|
|
(await Should.ThrowAsync<AbTransportException>(client.SendRRDataAsync(new byte[] { 0x4C }, Generous, TestCt)))
|
|
.Status.ShouldBe(AbStatus.BadReply);
|
|
client.IsConnected.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Dispose_sends_UnRegisterSession()
|
|
{
|
|
await using var server = new FakeEipServer();
|
|
var client = await Connect(server);
|
|
await client.DisposeAsync();
|
|
|
|
var frame = await server.WaitForFrameAsync(f => FakeEipServer.Command(f) == EncapCommand.UnRegisterSession, Guard);
|
|
BinaryPrimitives.ReadUInt32LittleEndian(frame.AsSpan(4)).ShouldBe(FakeEipServer.Session);
|
|
client.IsConnected.ShouldBeFalse();
|
|
(await Should.ThrowAsync<AbTransportException>(client.SendRRDataAsync(new byte[] { 0x4C }, Generous, TestCt)))
|
|
.Status.ShouldBe(AbStatus.BadConnection);
|
|
await client.DisposeAsync(); // idempotent
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Connect_to_a_closed_port_is_BadConnection()
|
|
{
|
|
var probe = new TcpListener(IPAddress.Loopback, 0);
|
|
probe.Start();
|
|
var port = ((IPEndPoint)probe.LocalEndpoint).Port;
|
|
probe.Stop();
|
|
|
|
(await Should.ThrowAsync<AbTransportException>(
|
|
EncapsulationClient.ConnectAsync("127.0.0.1", port, Generous, TestCt)))
|
|
.Status.ShouldBe(AbStatus.BadConnection);
|
|
}
|
|
}
|