Skip to content

Commit 618af12

Browse files
asdacapclaude
andauthored
fix(txpool): fix data race that broadcasts a null transaction (#12162)
* fix(txpool): fix data race that broadcasts a null transaction TxBroadcaster.BroadcastOnce locked on the _accumulatedTemporaryTxs instance while TimerOnElapsed swapped that field by reference via Interlocked.Exchange without taking the same lock. A monitor only serialises sections that lock the same stable object, so the swap let two threads hold monitors on two different ResettableList instances while both Add()-ing to the same underlying List<T>. A concurrent Add during a resize leaves a null hole in the list, which is later read lazily through txs.Where(_gossipFilter) and dereferenced by SpecDrivenTxGossipPolicy, throwing NullReferenceException in CompositeTxGossipPolicy.ShouldGossipTransaction while gossiping to peers. Use a dedicated, never-reassigned lock for both the append and the swap. After the swap, BroadcastOnce only touches the new (empty) accumulator while the timer exclusively owns the buffer being sent, so the two lists are never mutated concurrently. The ResettableList reuse/swap design is kept to avoid per-broadcast allocations during sync. The pre-existing race surfaces as a fatal crash now only because SpecDrivenTxGossipPolicy is the first gossip policy to dereference the transaction; it was observed on gnosis+Flat sync where finalization-driven background work shifts scheduling enough to hit the window. Adds a concurrency regression test that drives BroadcastOnce against repeated timer swaps and asserts no null reaches the peer (and that every transaction is sent exactly once). The test fails reliably on the old code and passes on the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(txpool): address review feedback - Remove the two comments @asdacap flagged as unnecessary (the XML doc on _accumulatedTxsLock and the inline comment in NotifyPeers). - Yield in the regression test's ticker loop so it no longer busy-spins a core; the swap window is still hit reliably (test still fails 5/5 on the pre-fix code, passes on the fix). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 760ccb2 commit 618af12

2 files changed

Lines changed: 73 additions & 2 deletions

File tree

src/Nethermind/Nethermind.TxPool.Test/TxBroadcasterTests.cs

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

44
using System;
5+
using System.Collections.Concurrent;
56
using System.Collections.Generic;
67
using System.Linq;
78
using System.Runtime.CompilerServices;
@@ -723,6 +724,72 @@ public void can_correctly_broadcast_light_transactions_without_wrappers([Values]
723724
Assert.That(result, Is.EqualTo(versionMatches), "LightTransaction from blob transaction should be gossiped when proof version matches.");
724725
}
725726

727+
[Test]
728+
public async Task Should_not_send_null_tx_when_adding_concurrently_with_timer_swap()
729+
{
730+
// Regression for the gossip NRE seen on gnosis+Flat: BroadcastOnce used to lock on the
731+
// _accumulatedTemporaryTxs instance while the timer swapped that field by reference without taking the same
732+
// lock. A concurrent Add could then mutate the swapped-out List<T> and leave a null hole, later dereferenced
733+
// while gossiping (NullReferenceException in CompositeTxGossipPolicy.ShouldGossipTransaction).
734+
ITimer timer = Substitute.For<ITimer>();
735+
ITimerFactory timerFactory = Substitute.For<ITimerFactory>();
736+
timerFactory.CreateTimer(Arg.Any<TimeSpan>()).Returns(timer);
737+
738+
_broadcaster = new TxBroadcaster(_comparer, timerFactory, _txPoolConfig, _headInfo, _logManager);
739+
740+
RecordingPeer peer = new(TestItem.PublicKeyA);
741+
_broadcaster.AddPeer(peer);
742+
743+
const int txCount = 30_000;
744+
Transaction[] transactions = new Transaction[txCount];
745+
for (int i = 0; i < txCount; i++)
746+
{
747+
transactions[i] = Build.A.Transaction.WithNonce((ulong)i).TestObject;
748+
}
749+
750+
using System.Threading.CancellationTokenSource cts = new();
751+
752+
// Keep firing the timer so NotifyPeers repeatedly swaps and flushes the accumulator while transactions are
753+
// still being added from other threads - this is the window the old lock failed to guard.
754+
Task ticker = Task.Run(() =>
755+
{
756+
while (!cts.IsCancellationRequested)
757+
{
758+
timer.Elapsed += Raise.Event<EventHandler>(timer, EventArgs.Empty);
759+
System.Threading.Thread.Yield();
760+
}
761+
});
762+
763+
Parallel.For(0, txCount, i => _broadcaster.Broadcast(transactions[i], isPersistent: false));
764+
765+
cts.Cancel();
766+
await ticker;
767+
// Final flush of whatever was still accumulated after the adders finished.
768+
timer.Elapsed += Raise.Event<EventHandler>(timer, EventArgs.Empty);
769+
770+
Assert.That(peer.SawNull, Is.False, "A null transaction reached the peer - the accumulated tx list was corrupted by a data race.");
771+
Assert.That(peer.Sent.Count, Is.EqualTo(txCount), "Every broadcast transaction should be sent exactly once.");
772+
Assert.That(peer.Sent.Distinct().Count(), Is.EqualTo(txCount), "No transaction should be sent more than once.");
773+
}
774+
775+
private sealed class RecordingPeer(PublicKey id) : ITxPoolPeer
776+
{
777+
private readonly ConcurrentBag<Transaction> _sent = [];
778+
public PublicKey Id => id;
779+
public ulong HeadNumber { get; set; }
780+
public bool SawNull { get; private set; }
781+
public IReadOnlyCollection<Transaction> Sent => _sent;
782+
783+
public void SendNewTransactions(IEnumerable<Transaction> txs, bool sendFullTx)
784+
{
785+
foreach (Transaction tx in txs)
786+
{
787+
if (tx is null) SawNull = true;
788+
else _sent.Add(tx);
789+
}
790+
}
791+
}
792+
726793
private (IList<Transaction> expectedTxs, IList<Hash256> expectedHashes) GetTxsAndHashesExpectedToBroadcast(Transaction[] transactions, int expectedCountTotal)
727794
{
728795
List<Transaction> expectedTxs = [];

src/Nethermind/Nethermind.TxPool/TxBroadcaster.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ internal class TxBroadcaster : IDisposable
5353
/// </summary>
5454
private ResettableList<Transaction> _txsToSend;
5555

56+
private readonly Lock _accumulatedTxsLock = new();
5657

5758
/// <summary>
5859
/// Minimal value of MaxFeePerGas of local tx to be broadcasted immediately after receiving it
@@ -131,7 +132,7 @@ private bool StartBroadcast(Transaction tx)
131132

132133
private void BroadcastOnce(Transaction tx)
133134
{
134-
lock (_accumulatedTemporaryTxs)
135+
lock (_accumulatedTxsLock)
135136
{
136137
_accumulatedTemporaryTxs.Add(tx);
137138
}
@@ -296,7 +297,10 @@ private void TimerOnElapsed(object? sender, EventArgs args)
296297
[MethodImpl(MethodImplOptions.AggressiveInlining)]
297298
void NotifyPeers()
298299
{
299-
_txsToSend = Interlocked.Exchange(ref _accumulatedTemporaryTxs, _txsToSend);
300+
lock (_accumulatedTxsLock)
301+
{
302+
(_accumulatedTemporaryTxs, _txsToSend) = (_txsToSend, _accumulatedTemporaryTxs);
303+
}
300304

301305
if (_logger.IsTrace) _logger.Trace($"Broadcasting transactions to all peers");
302306

0 commit comments

Comments
 (0)