Skip to content

Commit e3bea85

Browse files
Trent Houlistonclaude
andcommitted
Sync nuclear subtree from NUClear@2053a375
An unacknowledged reliable send now disconnects the peer rather than silently dropping the data, so a nuclear_leave is emitted where the message would previously have gone missing without any indication. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 1243bb7 commit e3bea85

6 files changed

Lines changed: 154 additions & 34 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Node.js module for interacting with the [NUClear](https://github.com/Fastcode/NU
88

99
Version 2 uses the redesigned **NUClearNet** library from [NUClear PR #190](https://github.com/Fastcode/NUClear/pull/190) (wire protocol **0x03**). It is **not** compatible with 1.x clients or NUClear builds that still use the old `NUClearNetwork` stack (protocol 0x02). Upgrade Node clients and NUClear robots together.
1010

11-
The vendored NUClear tree is updated via `git subtree` from the `houliston/nuclearnet-v2` branch (currently [NUClear@62bc83a1](https://github.com/Fastcode/NUClear/commit/62bc83a1)).
11+
The vendored NUClear tree is updated via `git subtree` from the `houliston/nuclearnet-v2` branch (currently [NUClear@2053a375](https://github.com/Fastcode/NUClear/commit/2053a375)).
1212

1313
Peer join events may arrive slightly later than in 1.x because connection requires both multicast announce and a unicast CONNECT handshake.
1414

src/nuclear/src/nuclearnet/NUClearNet.cpp

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,24 @@ namespace {
385385
discovery->check_timeouts(now);
386386

387387
// Check for retransmissions
388-
auto retransmissions = reliability->check_retransmissions(fragmentation->get_packet_mtu());
388+
std::vector<Reliability::UndeliverablePacket> undeliverable;
389+
auto retransmissions = reliability->check_retransmissions(fragmentation->get_packet_mtu(), undeliverable);
390+
391+
// A reliable send we can no longer make good on means the connection is broken, not that one message
392+
// was unlucky. Drop the peer so the caller sees a disconnect rather than silently losing the data;
393+
// they will reconnect off their next announce and the state is sent again from scratch.
394+
for (const auto& failure : undeliverable) {
395+
if (should_log(LogLevel::Warn)) {
396+
std::ostringstream msg;
397+
msg << "peer unreachable " << sock_str(failure.target) << ", dropping connection after "
398+
<< failure.retransmits << " retransmits of packet_id=" << failure.packet_id
399+
<< " hash=" << hash_hex(failure.hash) << " with " << failure.unacked << "/"
400+
<< failure.packet_count << " fragments unacknowledged";
401+
log(LogLevel::Warn, "reliability", msg.str());
402+
}
403+
// Takes the same path as a LEAVE packet, which clears our tracking and notifies the caller
404+
discovery->process_leave(failure.target);
405+
}
389406
if (should_log(LogLevel::Debug) && !retransmissions.empty()) {
390407
std::ostringstream msg;
391408
msg << "retransmit count=" << retransmissions.size();

src/nuclear/src/nuclearnet/Reliability.cpp

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
namespace NUClear {
4040
namespace network {
4141

42+
// Definitions for the in class initialised constants, needed until C++17 makes them implicitly inline
43+
constexpr uint16_t Reliability::MAX_RETRANSMITS;
44+
constexpr uint16_t Reliability::MAX_BACKOFF_SHIFT;
45+
constexpr std::chrono::seconds Reliability::MAX_RETRANSMIT_INTERVAL;
4246

4347
void Reliability::track_packet(const sock_t& target,
4448
uint16_t packet_id,
@@ -143,6 +147,14 @@ namespace network {
143147
std::vector<Reliability::RetransmitRequest> Reliability::check_retransmissions(
144148
uint16_t packet_mtu,
145149
std::chrono::steady_clock::time_point now) {
150+
std::vector<UndeliverablePacket> ignored;
151+
return check_retransmissions(packet_mtu, ignored, now);
152+
}
153+
154+
std::vector<Reliability::RetransmitRequest> Reliability::check_retransmissions(
155+
uint16_t packet_mtu,
156+
std::vector<UndeliverablePacket>& failed_peers,
157+
std::chrono::steady_clock::time_point now) {
146158
std::vector<RetransmitRequest> retransmissions;
147159

148160
const std::lock_guard<std::mutex> lock(tracking_mutex);
@@ -158,31 +170,34 @@ namespace network {
158170
}
159171

160172
// Back the timeout off exponentially while the packet goes unacknowledged, so a peer that cannot
161-
// acknowledge it is retried at a decaying rate rather than a constant one
162-
rto *= 1 << std::min(tp.retransmit_count, MAX_BACKOFF_SHIFT);
173+
// acknowledge it is retried at a decaying rate rather than a constant one. Bounded so that a peer
174+
// with a slow measured RTT is still declared unreachable in a sensible amount of time, but never
175+
// retried faster than its own timeout.
176+
const auto backed_off = rto * (1 << std::min(tp.retransmit_count, MAX_BACKOFF_SHIFT));
177+
rto = std::max(rto, std::min(backed_off, std::chrono::steady_clock::duration(
178+
MAX_RETRANSMIT_INTERVAL)));
163179

164180
// Check if it's time to retransmit
165181
if (now - tp.last_send < rto) {
166182
++it;
167183
continue;
168184
}
169185

170-
// Give up rather than retransmitting forever. Without this a single message the peer will never
171-
// acknowledge becomes a permanent load, and every further one adds to it.
186+
// Out of retries. Report the peer as unreachable rather than dropping the packet and moving on —
187+
// a reliable send promised this data arrived, so the caller has to be told the connection failed.
188+
// The packet stays tracked; it is cleaned up when the peer is removed.
172189
if (tp.retransmit_count >= MAX_RETRANSMITS) {
173-
if (should_log(LogLevel::Warn)) {
174-
std::size_t unacked = 0;
175-
for (uint16_t i = 0; i < tp.packet_count; ++i) {
176-
unacked += tp.acked[i] ? 0 : 1;
177-
}
178-
log(LogLevel::Warn,
179-
"reliability",
180-
"giving up on packet_id=" + std::to_string(tp.packet_id) + " hash=" + hash_hex(tp.hash)
181-
+ " peer=" + sock_str(tp.target) + " after " + std::to_string(tp.retransmit_count)
182-
+ " retransmits with " + std::to_string(unacked) + "/"
183-
+ std::to_string(tp.packet_count) + " fragments unacknowledged");
190+
UndeliverablePacket failure;
191+
failure.target = tp.target;
192+
failure.packet_id = tp.packet_id;
193+
failure.hash = tp.hash;
194+
failure.packet_count = tp.packet_count;
195+
failure.retransmits = tp.retransmit_count;
196+
for (uint16_t i = 0; i < tp.packet_count; ++i) {
197+
failure.unacked += tp.acked[i] ? 0 : 1;
184198
}
185-
it = tracked_packets.erase(it);
199+
failed_peers.push_back(failure);
200+
++it;
186201
continue;
187202
}
188203

src/nuclear/src/nuclearnet/Reliability.hpp

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,37 @@ namespace network {
5151
using sock_t = util::network::sock_t;
5252

5353
/**
54-
* How many times a packet group is retransmitted before it is given up on.
54+
* How many times a packet group is retransmitted before the peer is declared unreachable.
5555
*
56-
* A peer that stays connected but never acknowledges a particular packet would otherwise have it
57-
* retransmitted forever, so one undeliverable message becomes a permanent load that grows every time
58-
* another one joins it.
56+
* A reliable send promises the data arrived, so a packet that cannot be delivered is never abandoned in
57+
* favour of carrying on with the next one — that would silently break the guarantee the caller asked
58+
* for. As TCP does, exhausting the retries means the connection is broken rather than that one message
59+
* was unlucky, so the peer is dropped and has to reconnect.
5960
*/
6061
static constexpr uint16_t MAX_RETRANSMITS = 10;
6162

6263
/// How many times the retransmission timeout is allowed to double while a packet goes unacknowledged
6364
static constexpr uint16_t MAX_BACKOFF_SHIFT = 6;
6465

66+
/**
67+
* The longest the backoff is allowed to stretch the retransmission timeout to.
68+
*
69+
* The timeout is already capped at 60s for a genuinely slow peer, and doubling that six times would
70+
* take an hour to notice the peer had gone. The backoff never pushes the interval below the peer's own
71+
* timeout, so a slow link is still given the time it needs.
72+
*/
73+
static constexpr std::chrono::seconds MAX_RETRANSMIT_INTERVAL{5};
74+
75+
/// A packet that could not be delivered, and the peer it was destined for
76+
struct UndeliverablePacket {
77+
sock_t target{};
78+
uint16_t packet_id{0};
79+
uint64_t hash{0};
80+
uint16_t packet_count{0};
81+
uint16_t unacked{0};
82+
uint16_t retransmits{0};
83+
};
84+
6585
/// Information about a fragment that needs retransmitting
6686
struct RetransmitRequest {
6787
sock_t target{};
@@ -129,11 +149,22 @@ namespace network {
129149
/**
130150
* Check for packets that need retransmission and return them.
131151
*
132-
* @param packet_mtu The MTU to use for fragmenting retransmissions
133-
* @param now The current time (defaults to steady_clock::now())
152+
* @param packet_mtu The MTU to use for fragmenting retransmissions
153+
* @param failed_peers Filled with peers that have exhausted their retries and should be disconnected.
154+
* Their packets stay tracked until the peer is removed, so the caller must act on
155+
* this or the same peers will be reported again.
156+
* @param now The current time (defaults to steady_clock::now())
134157
*
135158
* @return List of fragments that need to be retransmitted
136159
*/
160+
std::vector<RetransmitRequest> check_retransmissions(
161+
uint16_t packet_mtu,
162+
std::vector<UndeliverablePacket>& failed_peers,
163+
std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now());
164+
165+
/**
166+
* Check for packets that need retransmission, ignoring any peers that have exhausted their retries.
167+
*/
137168
std::vector<RetransmitRequest> check_retransmissions(
138169
uint16_t packet_mtu,
139170
std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now());

src/nuclear/tests/tests/nuclearnet/ProcessPacket.cpp

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
#include <catch2/catch_test_macros.hpp>
2727
#include <cstdint>
2828
#include <cstring>
29+
#include <thread>
2930
#include <memory>
3031
#include <string>
3132
#include <utility>
@@ -529,3 +530,41 @@ SCENARIO("send to named peer that does not exist delivers nothing", "[nuclearnet
529530
std::vector<uint8_t> payload = {1, 2, 3};
530531
net->send(0x1234, payload.data(), payload.size(), "NonExistentPeer", false);
531532
}
533+
534+
SCENARIO("A peer that never acknowledges a reliable send is disconnected",
535+
"[nuclearnet][process_packet][.slow]") {
536+
if (!test_util::has_ipv4_multicast()) {
537+
SKIP("No multicast support");
538+
}
539+
540+
// A long peer timeout, so the ordinary "stopped announcing" path cannot be what disconnects them and
541+
// the only thing left to do it is the unacknowledged reliable send
542+
auto net = std::make_unique<NUClearNet>();
543+
NetworkConfig cfg;
544+
cfg.name = "TestNode";
545+
cfg.peer_timeout = std::chrono::seconds(60);
546+
net->reset(cfg);
547+
548+
// An address nothing will ever answer from, so the reliable send can never be acknowledged
549+
const sock_t peer = make_addr(0x0A000001, 5000);
550+
551+
bool left = false;
552+
net->set_leave_callback([&](const NUClear::network::PeerInfo& p) { left = (p.name == "Deaf"); });
553+
554+
establish_peer(*net, peer, "Deaf");
555+
556+
const std::vector<uint8_t> payload(64, 0xAB);
557+
net->send(0x1234, payload.data(), payload.size(), "Deaf", true);
558+
559+
// Drive the network until it gives up on the peer. The retransmit interval backs off and is capped, so
560+
// this settles well inside the timeout below.
561+
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(60);
562+
while (!left && std::chrono::steady_clock::now() < deadline) {
563+
net->process();
564+
std::this_thread::sleep_for(std::chrono::milliseconds(50));
565+
}
566+
567+
THEN("the caller is told the peer left rather than the data being dropped silently") {
568+
REQUIRE(left);
569+
}
570+
}

src/nuclear/tests/tests/nuclearnet/Reliability.cpp

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,13 @@ SCENARIO("Reliability retransmits until the peer is removed", "[nuclearnet][reli
126126

127127
// Each retransmission waits longer than the last, so step well past the backed off timeout each time
128128
for (std::size_t i = 0; i < 3; ++i) {
129-
t += std::chrono::seconds(60);
129+
t += std::chrono::hours(1);
130130
REQUIRE(rel.check_retransmissions(100, t).size() == 1);
131131
}
132132

133133
// Removing the peer cleans up all tracked packets
134134
rel.remove_peer(target);
135-
t += std::chrono::seconds(60);
135+
t += std::chrono::hours(1);
136136
REQUIRE(rel.check_retransmissions(100, t).empty());
137137
}
138138

@@ -165,30 +165,48 @@ SCENARIO("Reliability remove_peer removes all tracked state", "[nuclearnet][reli
165165
REQUIRE(retransmissions.empty());
166166
}
167167

168-
SCENARIO("Reliability gives up on a packet that is never acknowledged", "[nuclearnet][reliability]") {
168+
SCENARIO("Reliability reports the peer as unreachable when retries are exhausted", "[nuclearnet][reliability]") {
169169
Reliability rel;
170170

171171
const sock_t target = make_addr(0x0A000001, 5000);
172172
const std::vector<uint8_t> payload(100, 0xFF);
173173

174174
auto t = std::chrono::steady_clock::now();
175-
rel.track_packet(target, 1, 1, 0x1234, 0x01, payload.data(), payload.size(), t);
175+
rel.track_packet(target, 1, 4, 0x1234, 0x01, payload.data(), payload.size(), t);
176176

177177
// Walk far enough forward each time that the backed off timeout has always expired
178178
std::size_t rounds = 0;
179-
for (std::size_t i = 0; i < Reliability::MAX_RETRANSMITS + 5; ++i) {
180-
t += std::chrono::seconds(60);
181-
if (!rel.check_retransmissions(100, t).empty()) {
179+
std::vector<Reliability::UndeliverablePacket> failed;
180+
for (std::size_t i = 0; i < Reliability::MAX_RETRANSMITS + 3; ++i) {
181+
t += std::chrono::hours(1);
182+
if (!rel.check_retransmissions(100, failed, t).empty()) {
182183
++rounds;
183184
}
184185
}
185186

186-
THEN("it is retransmitted a bounded number of times and then dropped") {
187+
THEN("it is retransmitted a bounded number of times, then the peer is reported") {
187188
REQUIRE(rounds == Reliability::MAX_RETRANSMITS);
189+
REQUIRE_FALSE(failed.empty());
190+
REQUIRE(failed.front().target == target);
191+
REQUIRE(failed.front().packet_id == 1);
192+
REQUIRE(failed.front().hash == 0x1234);
193+
REQUIRE(failed.front().unacked == 4);
194+
REQUIRE(failed.front().retransmits == Reliability::MAX_RETRANSMITS);
195+
}
196+
197+
THEN("the packet is not silently dropped, it stays tracked until the peer is removed") {
198+
// Reported again rather than forgotten, because a reliable send must not be abandoned quietly
199+
failed.clear();
200+
t += std::chrono::hours(1);
201+
rel.check_retransmissions(100, failed, t);
202+
REQUIRE_FALSE(failed.empty());
188203

189-
// Nothing further, no matter how long we wait
204+
// Removing the peer is what clears it
205+
rel.remove_peer(target);
206+
failed.clear();
190207
t += std::chrono::hours(1);
191-
REQUIRE(rel.check_retransmissions(100, t).empty());
208+
REQUIRE(rel.check_retransmissions(100, failed, t).empty());
209+
REQUIRE(failed.empty());
192210
}
193211
}
194212

0 commit comments

Comments
 (0)