Files
node/test/mesh_transport_test.cpp
zeekayandHanzo Dev 602356a3c5 MSG_DONTWAIT is honoured on send only on Linux
The mesh transport test floods a peer socket and asks send not to wait. Linux
returns EAGAIN when the buffer is full; macOS blocks. The peer being flooded
has by then been evicted, so nothing is draining, and the test stopped in send
and never returned — taking the whole ctest run with it, since it is the first
test and everything after it never ran. Eleven minutes of a suite that reports
40 other tests as "Not Run" reads as a slow build rather than as one hang.

The peer end of the pair is non-blocking on the descriptor now, which both
platforms honour. The transport's own end is left blocking on purpose: the
frame reader documents that MSG_DONTWAIT keeps its fd blocking so that writes
elsewhere stay robust, and it reaches that fd only through recv, where
MSG_DONTWAIT works everywhere.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-05 14:19:59 -07:00

231 lines
9.5 KiB
C++

// Copyright (C) 2026, Lux Industries, Inc. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause-Eco
//
// mesh_transport_test.cpp — what one hostile or dead socket can do to this node.
//
// The transport is the node's whole attack surface before any signature is
// checked: a peer that has done nothing but connect can already make us read,
// buffer and write. Each case here is a way that used to cost us something and
// now costs the peer its seat:
//
// [1] a frame length larger than this link's frames → rejected, peer dropped,
// and — the part that matters — the buffer does NOT grow while the peer
// keeps sending. A 4 GiB length header followed by a flood used to be an
// unbounded allocation on the receiving node.
// [2] a length under the ZAP ceiling but over the vote cap is the same lie in
// a smaller hat, and is treated the same way.
// [3] a peer that votes and then hangs up still counts: the frames already
// reassembled are delivered BEFORE the corpse is evicted.
// [4] a socket that will not take a write is gone: broadcast drops it instead
// of writing to it again every round.
// [5] eviction is per-peer — the rest of the mesh is untouched.
#include "lux/consensus/zap/vote_codec.hpp"
#include "lux/node/mesh_vote_transport.hpp"
#include "lux/zap/wire.hpp"
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
#include <fcntl.h>
#include <sys/socket.h>
#include <unistd.h>
using namespace lux::node;
using namespace lux::consensus;
namespace {
int g_fail = 0;
void check(bool ok, const std::string& what) {
if (!ok) { std::printf(" ASSERT FAILED: %s\n", what.c_str()); ++g_fail; }
}
// A connected pair: `ours` goes to the transport, `theirs` is the peer we play.
struct Link {
int ours = -1, theirs = -1;
Link() {
int fds[2];
if (::socketpair(AF_UNIX, SOCK_STREAM, 0, fds) != 0) { std::puts("socketpair"); std::exit(2); }
ours = fds[0];
theirs = fds[1];
// The peer end is non-blocking on the FD, not per call. MSG_DONTWAIT is
// honoured on recv everywhere and on send only on Linux: on macOS the
// flood below fills the socket buffer and then blocks in send forever,
// because the transport has already evicted this peer and nothing is
// draining. The whole suite stopped on the first test, on one platform.
// `ours` is left blocking, which is the property the reader documents:
// MSG_DONTWAIT keeps the transport's own fd blocking so writes stay
// robust.
const int flags = ::fcntl(theirs, F_GETFL, 0);
if (flags >= 0) ::fcntl(theirs, F_SETFL, flags | O_NONBLOCK);
}
void close_theirs() { if (theirs >= 0) { ::close(theirs); theirs = -1; } }
~Link() { close_theirs(); }
};
// Raw bytes onto the peer end. Returns what the kernel took (a full socket
// buffer is normal when we are deliberately flooding).
std::size_t send_raw(int fd, const std::vector<std::uint8_t>& b) {
const ssize_t w = ::send(fd, b.data(), b.size(), MSG_DONTWAIT);
return w > 0 ? static_cast<std::size_t>(w) : 0;
}
std::vector<std::uint8_t> header(std::uint32_t len, std::uint8_t type) {
lux::zap::Writer w;
w.write_u32(len);
std::vector<std::uint8_t> b = w.take();
b.push_back(type);
return b;
}
// A structurally valid vote frame (decode_vote checks widths, not signatures).
std::vector<std::uint8_t> vote_frame(std::uint8_t tag) {
SignedVote v;
v.block_id.fill(tag);
v.voter.fill(0x01);
v.sig.fill(0x02);
const std::vector<std::uint8_t> payload = lux::consensus::zap::encode_vote(v);
std::vector<std::uint8_t> f = header(static_cast<std::uint32_t>(payload.size()),
lux::consensus::zap::kVoteMsgType);
f.insert(f.end(), payload.begin(), payload.end());
return f;
}
} // namespace
int main() {
std::printf("=============== node — mesh transport under a hostile peer ===============\n");
std::printf("one socket must never cost this node unbounded memory, a stalled write, or a seat it kept\n\n");
std::size_t delivered = 0;
auto counting_sink = [&delivered](const SignedVote&) { ++delivered; };
// [1] oversize length + flood: rejected, evicted, and the buffer stays flat.
{
MeshVoteTransport tx(counting_sink);
Link l;
tx.add_peer(l.ours);
send_raw(l.theirs, header(0xFFFFFFFFu, lux::consensus::zap::kVoteMsgType));
// Flood as hard as the peer can while the transport keeps pumping. The
// old reader latched its error flag and then went on buffering every one
// of these bytes; nothing consumed the flag, so the node grew without limit.
std::vector<std::uint8_t> junk(64 * 1024, 'A');
std::size_t sent = 0;
for (int i = 0; i < 256; ++i) {
sent += send_raw(l.theirs, junk);
tx.pump();
}
check(tx.peer_count() == 0, "[1] the peer that announced a 4 GiB frame is gone");
check(tx.evicted() == 1, "[1] exactly one eviction");
check(delivered == 0, "[1] no vote was delivered from the flood");
std::printf(" [1] flood: peer sent %zu KiB, peers left %zu, evictions %zu\n",
sent / 1024, tx.peer_count(), tx.evicted());
}
// [2] a length the ZAP ceiling would allow but this link never carries. The
// link's cap is kMaxMeshFrame, not a vote's 188 bytes: the same socket now
// carries transactions as well, and the reader that enforces the bound sees
// frames rather than meanings. What matters is that the bound is far below
// ZAP's 16 MiB and that crossing it costs the peer its place.
{
delivered = 0;
MeshVoteTransport tx(counting_sink);
Link l;
tx.add_peer(l.ours);
const std::uint32_t over = kMaxMeshFrame + 1;
check(over < lux::zap::MaxMessageSize, "[2] the test length is legal ZAP, illegal here");
send_raw(l.theirs, header(over, lux::consensus::zap::kVoteMsgType));
tx.pump();
check(tx.peer_count() == 0, "[2] a frame too large for this link drops the peer");
}
// [2b] a message type this node does not serve is DROPPED, and the peer that
// sent it keeps its place. Dispatch is by registration, so an unregistered
// type costs nothing — a peer cannot make this node work by naming one.
{
delivered = 0;
MeshVoteTransport tx(counting_sink);
Link l;
tx.add_peer(l.ours);
std::vector<std::uint8_t> f = header(4, 0x2A); // a type nothing registered
for (int i = 0; i < 4; ++i) f.push_back(0xEE);
send_raw(l.theirs, f);
tx.pump();
check(tx.peer_count() == 1, "[2b] an unserved message type does not drop the peer");
check(delivered == 0, "[2b] and delivers nothing");
}
// [2c] a registered type IS delivered, on the same socket votes ride.
{
delivered = 0;
std::size_t got = 0;
MeshVoteTransport tx(counting_sink);
tx.on(kTxMsgType, [&](const std::vector<std::uint8_t>& p) { got += p.size(); });
Link l;
tx.add_peer(l.ours);
std::vector<std::uint8_t> f = header(3, kTxMsgType);
for (int i = 0; i < 3; ++i) f.push_back(0x11);
send_raw(l.theirs, f);
tx.pump();
check(got == 3, "[2c] a registered message type reaches its handler");
check(tx.peer_count() == 1, "[2c] and the peer keeps its place");
}
// [3] a validator that votes and then hangs up still counts.
{
delivered = 0;
MeshVoteTransport tx(counting_sink);
Link l;
tx.add_peer(l.ours);
send_raw(l.theirs, vote_frame(0x42));
l.close_theirs(); // EOF arrives with the vote already in flight
tx.pump();
check(delivered == 1, "[3] the vote of a peer that then disconnected was delivered");
check(tx.peer_count() == 0, "[3] and the closed peer was evicted");
}
// [4] a socket that cannot take a write is gone, not retried forever.
{
delivered = 0;
MeshVoteTransport tx(counting_sink);
Link l;
tx.add_peer(l.ours);
l.close_theirs();
::shutdown(l.ours, SHUT_RDWR); // writes now fail outright
SignedVote v;
v.block_id.fill(0x43);
v.voter.fill(0x01);
v.sig.fill(0x02);
tx.broadcast(v);
check(delivered == 1, "[4] broadcast still self-echoes to our own gate");
check(tx.peer_count() == 0, "[4] the unwritable peer was dropped by broadcast");
}
// [5] eviction is per-peer: one hostile socket does not cost an honest one.
{
delivered = 0;
MeshVoteTransport tx(counting_sink);
Link bad, good;
tx.add_peer(bad.ours);
tx.add_peer(good.ours);
send_raw(bad.theirs, header(0xFFFFFFFFu, lux::consensus::zap::kVoteMsgType));
send_raw(good.theirs, vote_frame(0x44));
tx.pump();
check(tx.peer_count() == 1, "[5] the honest peer kept its seat");
check(delivered == 1, "[5] and its vote arrived");
send_raw(good.theirs, vote_frame(0x45));
tx.pump();
check(delivered == 2, "[5] the honest peer still works after the eviction");
}
std::printf("---------------------------------------------------------------------------\n");
if (g_fail) { std::printf("==== node MESH TRANSPORT: FAIL (%d) ====\n", g_fail); return 1; }
std::printf("==== node MESH TRANSPORT: PASS — a hostile peer costs itself, not the node ====\n");
return 0;
}