Skip to content

Commit db2ef91

Browse files
authored
Fix event handler leaks, reduce allocations, and refactor Network layer (#10753)
* Fix event handler leaks, reduce allocations, and refactor Network layer - Fix event handler leaks in ProtocolsManager, PeerManager, RlpxHost, DiscoveryManager, DiscoveryApp, CompositeDiscoveryApp, NodeLifecycleManager, and NettyDiscoveryHandler by tracking subscriptions and unsubscribing on disposal - Implement IDisposable on ProtocolsManager with SessionSubscription/ProtocolHandlerSubscription wrappers for deterministic event handler cleanup - Cache event handler and Func<> delegates in fields to avoid repeated allocations on subscribe/unsubscribe - Replace Action<T> delegates in MessageQueue/MessageDictionary with IMessageSender<T> interface to eliminate per-construction allocations - Rewrite PacketSender from async to synchronous with ContinueWith, removing async state machine overhead on the hot send path - Add custom DisconnectEventHandlers collection enabling safe unsubscribe during dispatch - Extract channel initializer classes in RlpxHost, replace closure-capturing lambdas with cached delegates and named methods - Convert NodeTable sort lambda to struct comparer - Improve shutdown ordering in PeerManager - Add RLP deserialization collection-size limits to Snap and Eth message serializers - Apply [MethodImpl(NoInlining)] to logging local functions with interpolated strings - Apply [DoesNotReturn, StackTraceHidden] to throw helpers - Replace ArgumentNullException constructor with ThrowIfNull * Add tests for event handler cleanup, disposal, shutdown, and deserialization limits - Add tests for event handler cleanup on session disposal - Add tests for ProtocolsManager.Dispose - Add tests for PeerPool shutdown and SessionMonitor stop - Add tests for SnapCapabilitySwitcher lifecycle - Add tests for CompositeNodeSource event forwarding - Add tests for ZeroFrameDecoder oversized frame rejection - Add tests for deserialization limit validation in Snap/Eth serializers - Add tests for MessageDictionary and MessageQueue with IMessageSender - Deduplicate test setup across Eth protocol handler tests (EthProtocolTestHelper) - Refactor NettyDiscoveryHandlerTests, HelloMessageSerializerTests, and GetTrieNodesMessageSerializerTests * Fix RetryCache use-after-return race, CTS leak, and shutdown ordering - Replace TryGetValue/TryAdd with GetOrAdd in RetryCache.Announced to eliminate TOCTOU race where timer thread could remove and pool-return a ConcurrentHashSet while another thread still holds a reference - Add using to CancellationTokenSource in RlpxHost.ConnectAsync to prevent timer resource leak on repeated outbound connect attempts - Move subscription detachment in RlpxHost.Shutdown to after channel close and event loop shutdown so Disconnected handlers fire during shutdown and sessions are properly disposed * Fix P2PMessageKey label caching on record struct Record struct copies lose mutable field state, so the `_labels ??=` pattern silently re-computed labels on every access. Replace with a static ConcurrentDictionary keyed by the struct value — the key space is bounded by (protocol versions × SpaceSize) which is a few dozen entries total. * Await PeerManager loop task in StopAsync and fix StartNew anti-pattern Task.Factory.StartNew(LongRunning) with an async delegate returns Task<Task> — the outer task completes at the first await, making LongRunning meaningless and ContinueWith premature. Replace with async Task + Task.Yield(). StopAsync previously returned Task.CompletedTask while the loop was still running. Now it awaits the loop task for clean shutdown. Add regression tests: Stop_does_not_hang, Disconnect_triggers_refill, No_slot_available_before_deadline_does_not_deadlock_refill. * Fix double-stop of DiscoveryApp during shutdown DiscoveryApp and DiscoveryV5App implement IStoppableService via IDiscoveryApp. When registered as container-owned singletons, ServiceStopperMiddleware calls StopAsync on them directly AND CompositeDiscoveryApp also calls StopAsync on them — producing the duplicate "Discovery shutdown complete" log message. Mark both as ExternallyOwned so only CompositeDiscoveryApp manages their lifecycle. fx Propagate cancellation through discovery node location LocateNodesAsync did not pass its CancellationToken into SendFindNode or WasMessageReceived. During shutdown, the discovery loop had to wait for all in-flight FindNode requests to time out (up to 8 rounds × N nodes × 500ms each ≈ 70 seconds) before the task completed. Thread the token through LocateNodesAsync → SendFindNodes → SendFindNode → WasMessageReceived, and add ThrowIfCancellationRequested at the top of each discovery round. WasMessageReceived now creates a linked CTS so the Task.Delay is cancelled immediately on shutdown. * fix: address Claude reviewer feedback on network PR - RetryCache: document that first announcer is not added to retry bag (intentional — it receives RequestRequired and requests directly) - RlpxHost.Shutdown: dispose orphaned sessions via DetachAndDispose (sessions whose Disconnected event never fired were leaked) - DisconnectEventHandlers: document unordered invocation due to swap-and-null removal pattern - PacketSender: document thread-safety of lazy-init (Netty guarantees single-threaded channel event delivery) - ZeroFrameDecoder: document 12 MiB frame size cap rationale - RlpxHost: remove commented-out logging setup code - PeerManager: replace var with explicit KeyValuePair type, add locking comment on _isStopping field * fix: CI failures from review feedback changes - DetachAndDispose: mark session as disconnected before disposing to avoid InvalidOperationException on sessions still in Initialized state - PeerManagerTests: relax assertion from exact count (1) to > 0 since ConnectTimeoutMs=0 allows multiple connections to be queued before the slot count updates * fix: dispose CancellationTokenSource in RlpxHost.Shutdown The delayCancellation CTS in the shutdown path was not disposed. Add using declaration to match the ConnectAsync path. * fix: address PR review feedback (round 2) - Move IsIPv4Multicast to NodeFilter as a shared helper, delegate from DiscoveryV5App - Rename LogTrace to TraceNodeFilteredByRateLimit for clarity - Replace magic number 4097/1025 in snap serializer tests with SnapMessageLimits constants + 1 * fix: use ArrayPoolListRef instead of allocating Task[] in WhenAllDiscoveryApps
1 parent f40ae6d commit db2ef91

File tree

80 files changed

+3547
-1054
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

80 files changed

+3547
-1054
lines changed

src/Nethermind/Nethermind.Core.Test/Modules/TestEnvironmentModule.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ protected override void Load(ContainerBuilder builder)
4545
.AddKeyedSingleton<IFullDb>(DbNames.DiscoveryNodes, (_) => new MemDb())
4646
.AddKeyedSingleton<IFullDb>(DbNames.DiscoveryV5Nodes, (_) => new MemDb())
4747
.AddSingleton<IChannelFactory, INetworkConfig>(networkConfig => new LocalChannelFactory(networkGroup ?? nameof(TestEnvironmentModule), networkConfig))
48+
.AddSingleton(NodeFilter.AcceptAll) // Disable inbound rate limiting for in-memory channels
4849

4950
.AddSingleton<PseudoNethermindRunner>()
5051
.AddSingleton<TestBlockchainUtil>()

src/Nethermind/Nethermind.Core/Collections/ArrayListCore.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Buffers;
3+
using System.Collections.Generic;
34
using System.Diagnostics;
45
using System.Diagnostics.CodeAnalysis;
56
using System.Runtime.CompilerServices;
@@ -138,6 +139,13 @@ public static void Sort(T[] array, int count, Comparison<T> comparison)
138139
if (count > 1) array.AsSpan(0, count).Sort(comparison);
139140
}
140141

142+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
143+
public static void Sort<TComparer>(T[] array, int count, TComparer comparer)
144+
where TComparer : IComparer<T>
145+
{
146+
if (count > 1) array.AsSpan(0, count).Sort(comparer);
147+
}
148+
141149
[MethodImpl(MethodImplOptions.AggressiveInlining)]
142150
public static void Reverse(T[] array, int count)
143151
{

src/Nethermind/Nethermind.Core/Collections/ArrayPoolList.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@ public void Sort(Comparison<T> comparison)
145145
ArrayPoolListCore<T>.Sort(_array, _count, comparison);
146146
}
147147

148+
public void Sort<TComparer>(TComparer comparer)
149+
where TComparer : IComparer<T>
150+
{
151+
GuardDispose();
152+
ArrayPoolListCore<T>.Sort(_array, _count, comparer);
153+
}
154+
148155
public int Capacity => _capacity;
149156

150157
bool IList.IsFixedSize => false;

src/Nethermind/Nethermind.Core/Collections/ArrayPoolListRef.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ public void AddRange(params IEnumerable<T> items)
6767
public void ReduceCount(int newCount) => ArrayPoolListCore<T>.ReduceCount(SafeArrayPool<T>.Shared, ref _array, ref _capacity, ref _count, newCount);
6868
public void Truncate(int newLength) => ArrayPoolListCore<T>.Truncate(newLength, _array, ref _count);
6969
public readonly void Sort(Comparison<T> comparison) => ArrayPoolListCore<T>.Sort(_array, _count, comparison);
70+
public readonly void Sort<TComparer>(TComparer comparer) where TComparer : IComparer<T> => ArrayPoolListCore<T>.Sort(_array, _count, comparer);
7071
public readonly void Reverse() => ArrayPoolListCore<T>.Reverse(_array, _count);
7172
public readonly ref T GetRef(int index) => ref ArrayPoolListCore<T>.GetRef(_array, index, _count);
7273
public readonly Span<T> AsSpan() => _array.AsSpan(0, _count);
Lines changed: 38 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,56 @@
1-
// SPDX-FileCopyrightText: 2022 Demerzel Solutions Limited
1+
// SPDX-FileCopyrightText: 2025 Demerzel Solutions Limited
22
// SPDX-License-Identifier: LGPL-3.0-only
33

44
using System;
55
using System.Collections.Generic;
6-
using System.Linq;
76

8-
namespace Nethermind.Crypto
7+
namespace Nethermind.Crypto;
8+
9+
public sealed class PrivateKeyGenerator : IPrivateKeyGenerator, IDisposable
910
{
10-
public class PrivateKeyGenerator : IPrivateKeyGenerator, IDisposable
11-
{
12-
private readonly ICryptoRandom _cryptoRandom;
13-
private readonly bool _disposeRandom = false;
11+
private readonly ICryptoRandom _cryptoRandom;
12+
private readonly bool _disposeRandom = false;
1413

15-
public PrivateKeyGenerator()
16-
{
17-
_cryptoRandom = new CryptoRandom();
18-
_disposeRandom = true;
19-
}
14+
public PrivateKeyGenerator()
15+
{
16+
_cryptoRandom = new CryptoRandom();
17+
_disposeRandom = true;
18+
}
2019

21-
public PrivateKeyGenerator(ICryptoRandom cryptoRandom)
22-
{
23-
_cryptoRandom = cryptoRandom;
24-
}
20+
public PrivateKeyGenerator(ICryptoRandom cryptoRandom)
21+
{
22+
_cryptoRandom = cryptoRandom;
23+
}
2524

26-
public PrivateKey Generate()
27-
{
28-
return Generate(1).First();
29-
}
30-
public IEnumerable<PrivateKey> Generate(int number)
25+
public PrivateKey Generate()
26+
{
27+
do
3128
{
32-
do
29+
byte[] bytes = _cryptoRandom.GenerateRandomBytes(32);
30+
if (SecP256k1.VerifyPrivateKey(bytes))
3331
{
34-
var bytes = _cryptoRandom.GenerateRandomBytes(32);
35-
if (SecP256k1.VerifyPrivateKey(bytes))
36-
{
37-
yield return new PrivateKey(bytes);
38-
if (--number == 0)
39-
{
40-
yield break;
41-
}
42-
}
43-
} while (true);
44-
}
32+
return new PrivateKey(bytes);
33+
}
34+
} while (true);
35+
}
4536

46-
public void Dispose()
37+
public IEnumerable<PrivateKey> Generate(int number)
38+
{
39+
do
4740
{
48-
if (_disposeRandom)
41+
yield return Generate();
42+
if (--number == 0)
4943
{
50-
_cryptoRandom.Dispose();
44+
yield break;
5145
}
46+
} while (true);
47+
}
48+
49+
public void Dispose()
50+
{
51+
if (_disposeRandom)
52+
{
53+
_cryptoRandom.Dispose();
5254
}
5355
}
5456
}

src/Nethermind/Nethermind.Init/Modules/DiscoveryModule.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: LGPL-3.0-only
33

44
using Autofac;
5+
using Autofac.Features.AttributeFilters;
56
using Nethermind.Api;
67
using Nethermind.Core;
78
using Nethermind.Crypto;
@@ -105,7 +106,6 @@ protected override void Load(ContainerBuilder builder)
105106

106107
.AddNetworkStorage(DbNames.DiscoveryNodes, "discoveryNodes")
107108
.AddNetworkStorage(DbNames.DiscoveryV5Nodes, "discoveryV5Nodes")
108-
.AddSingleton<DiscoveryV5App>()
109109

110110
.AddSingleton<INodeDistanceCalculator, NodeDistanceCalculator>()
111111
.AddSingleton<INodeTable, NodeTable>()
@@ -114,9 +114,14 @@ protected override void Load(ContainerBuilder builder)
114114
.AddSingleton<IDiscoveryManager, DiscoveryManager>()
115115
.AddSingleton<INodesLocator, NodesLocator>()
116116
.AddSingleton<DiscoveryPersistenceManager>()
117-
.AddSingleton<DiscoveryApp>()
118117

119118
;
119+
120+
// DiscoveryApp and DiscoveryV5App implement IStoppableService via IDiscoveryApp,
121+
// but their lifecycle is owned by CompositeDiscoveryApp. Mark ExternallyOwned so
122+
// ServiceStopperMiddleware does not double-stop them.
123+
builder.RegisterType<DiscoveryV5App>().AsSelf().WithAttributeFiltering().SingleInstance().ExternallyOwned();
124+
builder.RegisterType<DiscoveryApp>().AsSelf().WithAttributeFiltering().SingleInstance().ExternallyOwned();
120125
}
121126

122127

src/Nethermind/Nethermind.Init/Steps/InitializeNetwork.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ await InitPeer().ContinueWith(initPeerTask =>
140140
{
141141
SnapCapabilitySwitcher snapCapabilitySwitcher =
142142
new(_api.ProtocolsManager, _api.SyncModeSelector, _api.LogManager);
143+
_api.DisposeStack.Push(snapCapabilitySwitcher);
143144
snapCapabilitySwitcher.EnableSnapCapabilityUntilSynced();
144145
}
145146

src/Nethermind/Nethermind.Network.Discovery.Test/DiscoveryManagerTests.cs

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,20 @@
66
using System.Diagnostics;
77
using System.Linq;
88
using System.Net;
9+
using System.Threading;
910
using System.Threading.Tasks;
1011
using FluentAssertions;
1112
using Nethermind.Core;
1213
using Nethermind.Core.Crypto;
1314
using Nethermind.Core.Test;
1415
using Nethermind.Core.Test.Builders;
16+
using Nethermind.Core.Test.Modules;
17+
using Nethermind.Config;
1518
using Nethermind.Core.Timers;
1619
using Nethermind.Crypto;
1720
using Nethermind.Db;
1821
using Nethermind.Logging;
22+
using Nethermind.Network;
1923
using Nethermind.Network.Config;
2024
using Nethermind.Network.Discovery.Lifecycle;
2125
using Nethermind.Network.Discovery.Messages;
@@ -94,6 +98,20 @@ public async Task OnPingMessageTest()
9498
await _msgSender.Received().SendMsg(Arg.Is<PingMsg>(static m => m.FarAddress!.Address.ToString() == Host && m.FarAddress.Port == Port));
9599
}
96100

101+
[Test]
102+
public void OnPingMessage_WithUnexpectedDestination_IsIgnored()
103+
{
104+
IPEndPoint address = new(IPAddress.Parse(Host), Port);
105+
PingMsg ping = new(_publicKey, GetExpirationTime(), address, new IPEndPoint(IPAddress.Parse("203.0.113.9"), _nodeTable.MasterNode!.Port), new byte[32])
106+
{
107+
FarAddress = address
108+
};
109+
110+
_discoveryManager.OnIncomingMsg(ping);
111+
112+
_msgSender.DidNotReceive().SendMsg(Arg.Any<DiscoveryMsg>());
113+
}
114+
97115
[Test, Ignore("Add bonding"), Retry(3)]
98116
public void OnPongMessageTest()
99117
{
@@ -152,6 +170,34 @@ public void MemoryTest()
152170
}
153171
}
154172

173+
[Test]
174+
public void OnPingMessage_FromFilteredIp_IsIgnored()
175+
{
176+
// Setup with filtering enabled (default) — the filter has a 5-minute timeout per IP/subnet.
177+
// First ping from this IP creates a lifecycle manager and is processed.
178+
IPEndPoint address = new(IPAddress.Parse("203.0.113.50"), Port);
179+
PingMsg ping1 = new(TestItem.PublicKeyC, GetExpirationTime(), address, _nodeTable.MasterNode!.Address, new byte[32])
180+
{
181+
FarAddress = address
182+
};
183+
_discoveryManager.OnIncomingMsg(ping1);
184+
185+
// Second ping from the same IP but different node ID should be filtered
186+
// because the IP is already in the NodeFilter cache.
187+
PingMsg ping2 = new(TestItem.PublicKeyD, GetExpirationTime(), address, _nodeTable.MasterNode!.Address, new byte[32])
188+
{
189+
FarAddress = address
190+
};
191+
_discoveryManager.OnIncomingMsg(ping2);
192+
193+
// Only one lifecycle manager should have been created (for the first ping)
194+
INodeLifecycleManager? manager1 = _discoveryManager.GetNodeLifecycleManager(new Node(TestItem.PublicKeyC, "203.0.113.50", Port));
195+
INodeLifecycleManager? manager2 = _discoveryManager.GetNodeLifecycleManager(new Node(TestItem.PublicKeyD, "203.0.113.50", Port));
196+
197+
Assert.That(manager1, Is.Not.Null, "First node from the IP should be accepted");
198+
Assert.That(manager2, Is.Null, "Second node from the same IP should be filtered");
199+
}
200+
155201
private static long GetExpirationTime() => Timestamper.Default.UnixTime.SecondsLong + 20;
156202

157203
[Test, Ignore("Add bonding"), Retry(3)]
@@ -190,6 +236,33 @@ private void ReceiveSomePong()
190236
_discoveryManager.OnIncomingMsg(pongMsg);
191237
}
192238

239+
[Test]
240+
public async Task SendMessage_DropOldest_WhenQueueIsFull()
241+
{
242+
// Use a very slow rate (1/sec) so messages pile up in the queue
243+
SetupDiscoveryManager(new DiscoveryConfig()
244+
{
245+
MaxOutgoingMessagePerSecond = 1
246+
});
247+
248+
// Fire 600 fire-and-forget messages (exceeds bounded channel capacity of 512).
249+
// With DropOldest semantics, the oldest messages are evicted when full,
250+
// keeping the channel bounded and preventing unbounded task/memory growth.
251+
for (int i = 0; i < 600; i++)
252+
{
253+
FindNodeMsg msg = new(_publicKey, i, []);
254+
_discoveryManager.SendMessage(msg);
255+
}
256+
257+
// Allow the consumer to process a few messages
258+
await Task.Delay(50);
259+
260+
// With rate=1/sec, only ~1 message should have been actually sent in 50ms.
261+
// The key property: the channel is bounded at 512, so ~88 oldest messages were dropped.
262+
int sent = _msgSender.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IMsgSender.SendMsg));
263+
Assert.That(sent, Is.LessThan(520), "Bounded channel should prevent unbounded send accumulation");
264+
}
265+
193266
[Test]
194267
[Repeat(10)]
195268
public async Task RateLimitOutgoingMessage()
@@ -209,5 +282,90 @@ public async Task RateLimitOutgoingMessage()
209282
await _discoveryManager.SendMessageAsync(msg);
210283
Stopwatch.GetElapsedTime(startTime).Should().BeGreaterThanOrEqualTo(TimeSpan.FromSeconds(0.9));
211284
}
285+
286+
[Test]
287+
public async Task SendMessage_StartsOnlyOneQueueConsumer_WhenFirstUseIsConcurrent()
288+
{
289+
SetupDiscoveryManager(new DiscoveryConfig
290+
{
291+
MaxOutgoingMessagePerSecond = 1000
292+
});
293+
294+
const int concurrency = 16;
295+
Task[] sendTasks = new Task[concurrency];
296+
ManualResetEventSlim start = new(false);
297+
298+
for (int i = 0; i < concurrency; i++)
299+
{
300+
int expiration = i;
301+
sendTasks[i] = Task.Run(() =>
302+
{
303+
start.Wait();
304+
_discoveryManager.SendMessage(new FindNodeMsg(_publicKey, expiration, []));
305+
});
306+
}
307+
308+
start.Set();
309+
await Task.WhenAll(sendTasks);
310+
311+
((DiscoveryManager)_discoveryManager).SendQueueConsumersCreated.Should().Be(1);
312+
}
313+
}
314+
315+
[Parallelizable(ParallelScope.Self)]
316+
[TestFixture]
317+
public class DiscoveryAppTests
318+
{
319+
private const string TestPrivateKeyHex = "0x3a1076bf45ab87712ad64ccb3b10217737f7faacbf2872e88fdd9a537d8fe266";
320+
321+
[Test]
322+
public async Task StopAsync_ShouldIgnoreDiscoveredNodesAfterStop()
323+
{
324+
PrivateKey privateKey = new(TestPrivateKeyHex);
325+
IProtectedPrivateKey nodeKey = new InsecureProtectedPrivateKey(privateKey);
326+
INodesLocator nodesLocator = Substitute.For<INodesLocator>();
327+
IDiscoveryManager discoveryManager = Substitute.For<IDiscoveryManager>();
328+
INodeTable nodeTable = Substitute.For<INodeTable>();
329+
IMessageSerializationService messageSerializationService = Substitute.For<IMessageSerializationService>();
330+
ICryptoRandom cryptoRandom = Substitute.For<ICryptoRandom>();
331+
INetworkStorage discoveryStorage = Substitute.For<INetworkStorage>();
332+
IProcessExitSource processExitSource = Substitute.For<IProcessExitSource>();
333+
processExitSource.Token.Returns(CancellationToken.None);
334+
335+
Node masterNode = new(privateKey.PublicKey, IPAddress.Loopback.ToString(), 30303);
336+
nodeTable.MasterNode.Returns(masterNode);
337+
338+
DiscoveryConfig discoveryConfig = new();
339+
NetworkConfig networkConfig = new();
340+
ILogManager logManager = LimboLogs.Instance;
341+
DiscoveryPersistenceManager persistenceManager = new(discoveryStorage, discoveryManager, discoveryConfig, logManager);
342+
343+
DiscoveryApp discoveryApp = new(
344+
nodeKey,
345+
nodesLocator,
346+
discoveryManager,
347+
nodeTable,
348+
messageSerializationService,
349+
cryptoRandom,
350+
discoveryStorage,
351+
persistenceManager,
352+
processExitSource,
353+
networkConfig,
354+
discoveryConfig,
355+
Timestamper.Default,
356+
logManager);
357+
358+
int addedNodes = 0;
359+
discoveryApp.NodeAdded += (_, _) => addedNodes++;
360+
361+
Node discoveredNode = new(TestItem.PublicKeyA, "192.168.10.5", 30303);
362+
discoveryManager.NodeDiscovered += Raise.EventWith(new NodeEventArgs(discoveredNode));
363+
Assert.That(addedNodes, Is.EqualTo(1));
364+
365+
await discoveryApp.StopAsync();
366+
367+
discoveryManager.NodeDiscovered += Raise.EventWith(new NodeEventArgs(discoveredNode));
368+
Assert.That(addedNodes, Is.EqualTo(1));
369+
}
212370
}
213371
}

0 commit comments

Comments
 (0)