Skip to content

Commit d7b5013

Browse files
authored
core(socket): outbound write queue — stop overlapping composed writes losing shares (#874)
core::Socket::write started a composed boost::asio::async_write immediately, with no outbound queue. Asio forbids initiating a second composed write on a descriptor before the first completes: the two async_write_some continuations are serviced FIFO per descriptor, so any message needing more than one round gets bytes from the other message spliced into its middle. The peer sees a bad length/checksum and drops us. This is reachable on every coin. send_shares issues three back-to-back writes on one socket in one handler turn (remember_tx -> shares -> forget_tx): dash/node.cpp:1346/1357/1362, ltc:756/777/784, btc:757/778/785, dgb:737/758/765, bch:760/781/788. handle_version writes getaddrs/addrme before the up-to-32 KB have_tx advert. Because send_shares reports its hashes as "sent" on SUBMISSION and broadcast_share never retries, a spliced frame loses that share to that peer permanently — silent PPLNS loss with no counter and no log line. Fix: a per-socket std::deque of framed buffers plus a writing_ in-flight flag. write() frames on the caller's thread exactly as before, queues, and starts the drain only when nothing is in flight; do_write() starts the next composed write only from the previous one's completion handler, so at most ONE composed async_write is ever outstanding per socket. Same shape as the working StratumSession::send_response/do_write already in core/stratum_server.cpp. Thread-safety: writes are submitted from node/compute threads as well as the io_context thread, which is how the bug is reachable. m_write_mutex is a LEAF lock held only around the deque/flag mutation — never across async_write initiation, an m_node callback, or any other lock — so no new lock-order edge. The empty-queue check and the writing_ clear happen under the same lock, so a concurrent submit can never lose the wakeup. Semantics preserved: on write error the whole queued chain fails together and error() is reported once, exactly as a single failed write did; a write onto an already-closed socket stays a silent no-op; the single-write path is byte-identical. Tests (folded into the existing allowlisted core_test target, never a new add_executable — that is the "Not Run" trap): src/core/test/ socket_write_queue_test.cpp drives a real loopback TCP pair with a squeezed send buffer so each message needs many write_some rounds, then asserts overlapping writes arrive concatenated in submission order, that concurrent writers from several threads still produce whole checksum-valid frames, that a single write is byte-identical, and that writes on a closed socket are dropped without an error callback. The first two FAIL on the pre-fix tree (byte streams diverge mid-payload with the next message's magic prefix spliced in). Fixes #863
1 parent 12b71e4 commit d7b5013

4 files changed

Lines changed: 494 additions & 1 deletion

File tree

src/core/socket.cpp

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,18 +120,92 @@ void Socket::write(std::unique_ptr<RawMessage> msg_data)
120120
}
121121

122122
auto packet = std::make_shared<PackStream>(Packet::from_message(m_node->get_prefix(), msg_data));
123+
124+
// Queue, then start the drain ONLY if no composed write is in flight. The
125+
// framing is done here (on the caller's thread, as before) so the bytes are
126+
// captured in submission order; the wire order is then exactly the order in
127+
// which callers reached this line. See the m_write_queue comment in
128+
// socket.hpp for why overlapping composed writes cost shares.
129+
bool start_drain = false;
130+
{
131+
std::lock_guard<std::mutex> lock(m_write_mutex);
132+
m_write_queue.push_back(std::move(packet));
133+
if (!m_writing)
134+
{
135+
m_writing = true;
136+
start_drain = true;
137+
}
138+
}
139+
140+
// Outside the lock: do_write() initiates asio work and must never run with
141+
// m_write_mutex held (leaf-lock discipline).
142+
if (start_drain)
143+
do_write();
144+
}
145+
146+
void Socket::fail_write_queue()
147+
{
148+
std::deque<std::shared_ptr<PackStream>> dropped;
149+
{
150+
std::lock_guard<std::mutex> lock(m_write_mutex);
151+
dropped.swap(m_write_queue);
152+
m_writing = false;
153+
}
154+
// `dropped` is released here, outside the lock.
155+
}
156+
157+
void Socket::do_write()
158+
{
159+
std::shared_ptr<PackStream> packet;
160+
{
161+
std::lock_guard<std::mutex> lock(m_write_mutex);
162+
if (m_write_queue.empty())
163+
{
164+
m_writing = false;
165+
return;
166+
}
167+
packet = m_write_queue.front();
168+
}
169+
170+
// Socket closed under us between queueing and draining: fail the whole
171+
// chain rather than leave buffers pinned behind a stuck in-flight flag.
172+
// Matches the pre-queue behaviour, where write() on a closed socket was a
173+
// silent no-op with no error() callback.
174+
if (!m_status || !m_socket || !m_socket->is_open())
175+
{
176+
fail_write_queue();
177+
return;
178+
}
179+
123180
boost::asio::async_write(*m_socket, boost::asio::buffer(packet->data(), packet->size()),
124181
[self = shared_from_this(), this, packet](const boost::system::error_code& ec, std::size_t length)
125182
{
126183
if (!ec) g_bytes_sent.fetch_add(length, std::memory_order_relaxed);
127184
if (ec) {
185+
// A failed write kills the connection, so everything still
186+
// queued behind it could never be delivered either: drop the
187+
// chain and report ONCE — exactly what a single failed write
188+
// did before the queue existed.
189+
fail_write_queue();
128190
std::shared_ptr<INetwork> strong;
129191
if (!acquire_node(strong)) {
130192
abort_connection();
131193
return;
132194
}
133195
m_node->error("Socket::write error: " + ec.message(), get_addr());
196+
return;
197+
}
198+
199+
{
200+
std::lock_guard<std::mutex> lock(m_write_mutex);
201+
if (!m_write_queue.empty())
202+
m_write_queue.pop_front();
134203
}
204+
// Start the NEXT composed write only from here — that ordering is
205+
// the whole point of the queue. Not unbounded recursion:
206+
// async_write never invokes its handler inline from the initiating
207+
// call, so each do_write() returns before the next one runs.
208+
do_write();
135209
}
136210
);
137211
}

src/core/socket.hpp

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
// SPDX-License-Identifier: AGPL-3.0-or-later
22
#pragma once
33

4+
#include <deque>
45
#include <map>
6+
#include <mutex>
57
#include <vector>
68
#include <string>
79
#include <memory>
@@ -73,6 +75,42 @@ class Socket : public std::enable_shared_from_this<Socket>
7375
NetService m_addr;
7476
NetService m_addr_local;
7577

78+
// ── Outbound write queue (issue #863) ──────────────────────────────────
79+
//
80+
// Asio forbids initiating a second COMPOSED write on a descriptor before
81+
// the first one completes: the two async_write_some continuations are
82+
// serviced FIFO per descriptor, so a message needing more than one round
83+
// gets bytes from the other message spliced into its middle. The peer then
84+
// sees a bad length/checksum and drops the connection.
85+
//
86+
// Every coin's send_shares issues three back-to-back writes on one socket
87+
// in a single handler turn (remember_tx -> shares -> forget_tx), and
88+
// handle_version writes getaddrs/addrme before the (potentially ~32 KB)
89+
// have_tx advert. Because send_shares reports its hashes as "sent" on
90+
// SUBMISSION and broadcast_share never retries, a spliced frame loses that
91+
// share to that peer permanently — silent PPLNS loss.
92+
//
93+
// Fix: buffer composed writes here and drain strictly in submission order,
94+
// starting the next one only from the previous completion handler, so at
95+
// most ONE composed async_write is ever in flight per socket. Same shape as
96+
// StratumSession::send_response/do_write (core/stratum_server.cpp).
97+
//
98+
// m_write_mutex is a LEAF lock: it is only ever held around the deque/flag
99+
// mutation and is never held across async_write initiation, an m_node
100+
// callback, or any other lock — writes are submitted from node/compute
101+
// threads as well as from the io_context thread, so the queue itself must
102+
// be guarded, but no new lock-order edge is introduced.
103+
//
104+
// Deliberately UNCAPPED, unlike StratumSession's backlog cap: before this
105+
// queue existed a stalled peer already pinned one PackStream per
106+
// overlapping in-flight async_write, so the memory profile is unchanged,
107+
// and dropping a queued p2p message would reintroduce exactly the silent
108+
// share loss this fixes. A stuck peer is still reaped by the existing
109+
// read-side error path.
110+
mutable std::mutex m_write_mutex;
111+
std::deque<std::shared_ptr<PackStream>> m_write_queue;
112+
bool m_writing {false}; // true while a composed async_write is in flight
113+
76114
public:
77115
// Global P2P traffic counters (all sockets combined)
78116
static inline std::atomic<uint64_t> g_bytes_recv{0};
@@ -94,6 +132,16 @@ class Socket : public std::enable_shared_from_this<Socket>
94132

95133
void read(); // moved out-of-line; needs INetwork complete via acquire_node
96134

135+
// Drain step of the outbound queue: initiates the composed async_write for
136+
// the front element and re-arms itself from that write's completion
137+
// handler. Never called while another composed write is in flight.
138+
void do_write();
139+
140+
// Drop every still-queued buffer and clear the in-flight flag. Used on
141+
// write error / closed socket so the whole queued chain fails together,
142+
// exactly as the single pre-queue write failed as a unit.
143+
void fail_write_queue();
144+
97145
void read_prefix(std::shared_ptr<Packet> packet);
98146
void read_command(std::shared_ptr<Packet> packet);
99147
void read_length(std::shared_ptr<Packet> packet);

src/core/test/CMakeLists.txt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,15 @@ if (BUILD_TESTING AND (GTest_FOUND OR GTEST_FOUND))
1515
# EXISTING allowlisted core_test target (never a new add_executable — that is
1616
# the #769 "Not Run" trap), and one KAT covers every coin because all five
1717
# node lanes consume this one shared helper.
18-
add_executable(core_test pack_test.cpp events_test.cpp chain_test.cpp packet_test.cpp block_broadcast_test.cpp broadcast_convergence_matrix_test.cpp core_merkle_branches_test.cpp version_gate_test.cpp filesystem_test.cpp web_server_submitblock_test.cpp known_txs_eviction_test.cpp tx_advertiser_test.cpp)
18+
#
19+
# socket_write_queue_test.cpp: the core::Socket OUTBOUND WRITE QUEUE (#863).
20+
# Drives a real loopback TCP pair with a small send buffer and submits
21+
# overlapping composed writes from off-io threads; without the queue those
22+
# writes splice bytes mid-message and the assertions fail. One KAT covers
23+
# every coin because all five node lanes share this one socket layer. Same
24+
# reasoning as above: FOLDED into the EXISTING allowlisted core_test target,
25+
# never a new add_executable (the #769 "Not Run" trap).
26+
add_executable(core_test pack_test.cpp events_test.cpp chain_test.cpp packet_test.cpp block_broadcast_test.cpp broadcast_convergence_matrix_test.cpp core_merkle_branches_test.cpp version_gate_test.cpp filesystem_test.cpp web_server_submitblock_test.cpp known_txs_eviction_test.cpp tx_advertiser_test.cpp socket_write_queue_test.cpp)
1927
target_link_libraries(core_test PRIVATE GTest::gtest_main core c2pool_merged_mining GTest::gtest)
2028
target_link_libraries(core_test PRIVATE c2pool_payout c2pool_hashrate ltc_coin) # OBJECT-lib SCC direct-naming (#22/#39)
2129

0 commit comments

Comments
 (0)