Skip to content

Commit ea8550a

Browse files
Trent Houlistonclaude
andcommitted
Send the native NUClearNet logs to the JavaScript logger
The library wrote its logs straight to stderr while our own messages went through console.error with a [NUClearNet.js] prefix, so turning debug on gave you two differently formatted streams and the native half ignored any redirection of console. NUClear now lets an embedder install a log handler, so hand the native messages to the same _log path as everything else. The component the message came from is included as a field. Also syncs the vendored nuclear subtree from NUClear@b7caa31c, which brings in the log handler along with the SO_REUSEADDR/SO_REUSEPORT pairing and member initializer changes made since 18c2877b. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b7932f2 commit ea8550a

34 files changed

Lines changed: 860 additions & 248 deletions

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@18c2877b](https://github.com/Fastcode/NUClear/commit/18c2877b)).
11+
The vendored NUClear tree is updated via `git subtree` from the `houliston/nuclearnet-v2` branch (currently [NUClear@b7caa31c](https://github.com/Fastcode/NUClear/commit/b7caa31c)).
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

index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export interface NUClearNetOptions {
3535

3636
/**
3737
* Enable debug logging. `true` is equivalent to `info`.
38-
* Native logs go to stderr; JavaScript logs use `console.error` with a `[NUClearNet.js]` prefix.
38+
* Logs from the native library and from JavaScript both use `console.error` with a `[NUClearNet.js]` prefix.
3939
* The `NUCLEARNET_DEBUG` environment variable applies when this option is omitted.
4040
*/
4141
debug?: boolean | 'off' | 'error' | 'warn' | 'info' | 'debug' | 'trace';

index.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ class NUClearNet extends EventEmitter {
7070
this._net.onJoin(this._onJoin.bind(this));
7171
this._net.onLeave(this._onLeave.bind(this));
7272
this._net.onWait(this._onWait.bind(this));
73+
this._net.onLog(this._onNativeLog.bind(this));
7374

7475
this._applyLogLevel(parseLogLevel(this._constructorDebug, process.env.NUCLEARNET_DEBUG));
7576
}
@@ -79,6 +80,19 @@ class NUClearNet extends EventEmitter {
7980
this._net.setLogLevel(level);
8081
}
8182

83+
/**
84+
* A log message from the native NUClearNet library.
85+
* The native side has already filtered by level, but check again so a level change that
86+
* races with a queued message can't slip through.
87+
*
88+
* @param {number} level
89+
* @param {string} component
90+
* @param {string} message
91+
*/
92+
_onNativeLog(level, component, message) {
93+
this._log(level, message, { component: component });
94+
}
95+
8296
/**
8397
* @param {number} level
8498
* @param {string} message

src/NetworkBinding.cpp

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ NetworkBinding::NetworkBinding(const Napi::CallbackInfo& info) : Napi::ObjectWra
137137
this->net.set_socket_change_callback([this]() { this->request_listener_restart(); });
138138
}
139139

140+
NetworkBinding::~NetworkBinding() {
141+
// The log handler is global and captures this, so make sure it can't outlive us even if destroy() was missed
142+
network::NUClearNet::set_log_handler(nullptr);
143+
}
144+
140145
void NetworkBinding::stop_listener() {
141146
++this->listener_generation;
142147

@@ -340,6 +345,26 @@ void NetworkBinding::OnWait(const Napi::CallbackInfo& info) {
340345
});
341346
}
342347

348+
void NetworkBinding::OnLog(const Napi::CallbackInfo& info) {
349+
Napi::Env env = info.Env();
350+
351+
on_log = Napi::ThreadSafeFunction::New(env, info[0].As<Napi::Function>(), "OnLog", 0, 1);
352+
353+
// Hand the native logs to the JavaScript logger so they come out the same way as our own messages.
354+
// This is a global handler, so the most recently created network wins if there is more than one.
355+
network::NUClearNet::set_log_handler(
356+
[this](network::LogLevel level, const char* component, const std::string& message) {
357+
on_log.BlockingCall(
358+
[level, component = std::string(component), message](Napi::Env env, Napi::Function js_callback) {
359+
js_callback.Call({
360+
Napi::Number::New(env, static_cast<int>(level)),
361+
Napi::String::New(env, component),
362+
Napi::String::New(env, message),
363+
});
364+
});
365+
});
366+
}
367+
343368
void NetworkBinding::Reset(const Napi::CallbackInfo& info) {
344369
Napi::Env env = info.Env();
345370

@@ -468,6 +493,9 @@ void NetworkBinding::Destroy(const Napi::CallbackInfo& info) {
468493

469494
this->stop_listener();
470495

496+
// Put the native logs back on stderr before releasing the function they were going to
497+
network::NUClearNet::set_log_handler(nullptr);
498+
471499
this->net.set_socket_change_callback([]() {});
472500
this->net.set_packet_callback(
473501
[](const sock_t&, const std::string&, uint64_t, bool, std::vector<uint8_t>&&) {});
@@ -479,6 +507,9 @@ void NetworkBinding::Destroy(const Napi::CallbackInfo& info) {
479507
on_join.Release();
480508
on_leave.Release();
481509
on_wait.Release();
510+
if (this->on_log) {
511+
this->on_log.Release();
512+
}
482513
if (this->listener_restart) {
483514
this->listener_restart.Release();
484515
}
@@ -523,6 +554,9 @@ void NetworkBinding::Init(Napi::Env env, Napi::Object exports) {
523554
InstanceMethod<&NetworkBinding::SetLogLevel>(
524555
"setLogLevel",
525556
static_cast<napi_property_attributes>(napi_writable | napi_configurable)),
557+
InstanceMethod<&NetworkBinding::OnLog>(
558+
"onLog",
559+
static_cast<napi_property_attributes>(napi_writable | napi_configurable)),
526560
InstanceMethod<&NetworkBinding::Destroy>(
527561
"destroy",
528562
static_cast<napi_property_attributes>(napi_writable | napi_configurable))});

src/NetworkBinding.hpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@ class NetworkListener;
3131
class NetworkBinding : public Napi::ObjectWrap<NetworkBinding> {
3232
public:
3333
NetworkBinding(const Napi::CallbackInfo& info);
34+
~NetworkBinding();
3435

3536
Napi::Value Hash(const Napi::CallbackInfo& info);
3637
void Send(const Napi::CallbackInfo& info);
3738
void OnPacket(const Napi::CallbackInfo& info);
3839
void OnJoin(const Napi::CallbackInfo& info);
3940
void OnLeave(const Napi::CallbackInfo& info);
4041
void OnWait(const Napi::CallbackInfo& info);
42+
void OnLog(const Napi::CallbackInfo& info);
4143
void Reset(const Napi::CallbackInfo& info);
4244
void Process(const Napi::CallbackInfo& info);
4345
void Shutdown(const Napi::CallbackInfo& info);
@@ -57,6 +59,7 @@ class NetworkBinding : public Napi::ObjectWrap<NetworkBinding> {
5759
Napi::ThreadSafeFunction on_join;
5860
Napi::ThreadSafeFunction on_leave;
5961
Napi::ThreadSafeFunction on_wait;
62+
Napi::ThreadSafeFunction on_log;
6063
Napi::ThreadSafeFunction listener_restart;
6164

6265
#ifdef _WIN32

src/nuclear/docs/explanation/nuclearnet.md

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,12 +153,46 @@ The connection is only considered "up" once both the announce path and data path
153153

154154
### Announce address options
155155

156+
The announce address determines how discovery packets are delivered.
157+
All nodes in a mesh must agree on the same address and port.
158+
For multi-peer discovery on one host, the address must fan out to every process bound to the shared announce port.
159+
160+
#### Socket binding
161+
162+
NUClearNet binds the announce socket to **all interfaces** (`INADDR_ANY`) by default, even when the announce address is multicast or broadcast.
163+
This default is required for broadcast fan-out on macOS.
164+
An explicit `bind_address` to a specific interface IP can prevent broadcast reception on macOS.
165+
166+
#### Reuse options
167+
168+
NUClearNet sets **`SO_REUSEADDR`** on all platforms and **`SO_REUSEPORT`** when the platform provides it — the two options are always paired where `SO_REUSEPORT` exists.
169+
Platforms without `SO_REUSEPORT` use `SO_REUSEADDR` alone.
170+
Socket setup is consistent; fan-out vs load-balance depends on the announce address and OS stack.
171+
172+
#### Address types and multi-peer validity
173+
156174
The announce address can be:
157175

158176
- **Multicast** (e.g., `239.226.152.162`) — the most common setup.
159-
All nodes on the same network join the multicast group and hear each other's announcements.
160-
- **Broadcast** (e.g., `255.255.255.255`) — works on simple LANs without multicast support.
161-
- **Unicast** — for point-to-point setups or testing.
177+
All nodes join the multicast group and hear each other's announcements.
178+
Valid for multi-peer on one host on both Linux and macOS (recommended on macOS).
179+
- **Subnet broadcast** (e.g., `192.168.1.255`) — all nodes on the subnet receive announce messages.
180+
Valid for multi-peer on one host on both platforms (requires default `INADDR_ANY` bind on macOS).
181+
- **Global broadcast** (`255.255.255.255`) — valid for multi-peer on one host on both platforms (noisy; requires default bind on macOS).
182+
- **Loopback broadcast** (`127.255.255.255`) — valid for multi-peer local dev on **Linux only**.
183+
macOS does not deliver UDP to this address locally — a macOS stack limitation, not reuse-option behavior.
184+
- **Unicast** (e.g., `127.0.0.1`, `192.168.1.50`) — for point-to-point setups between two known peers.
185+
Unicast does not fan out to every socket bound on the shared announce port — **invalid for multi-peer on one host** on both platforms.
186+
187+
#### Quick reference — multi-peer on one host
188+
189+
| Address | Linux | macOS |
190+
| ------- | ----- | ----- |
191+
| Multicast `239.226.152.162` | Valid | Valid (recommended) |
192+
| Loopback broadcast `127.255.255.255` | Valid (recommended local dev) | **Invalid** |
193+
| Subnet broadcast `x.x.x.255` | Valid | Valid (default bind) |
194+
| Global broadcast `255.255.255.255` | Valid | Valid (default bind) |
195+
| Unicast `127.0.0.1` / specific IP | **Invalid** | **Invalid** |
162196

163197
### NAT-friendly port learning
164198

@@ -851,12 +885,37 @@ emit(std::make_unique<NetworkConfiguration>(
851885
| `name` | `string` || Unique name for this node on the network |
852886
| `announce_address` | `string` | `"239.226.152.162"` | Address for node discovery announcements |
853887
| `announce_port` | `uint16_t` | `7447` | Port for announce messages |
854-
| `bind_address` | `string` | `""` (all) | Local interface to bind to |
888+
| `bind_address` | `string` | `""` (all) | Local interface to bind to (default `INADDR_ANY`; required for broadcast fan-out on macOS) |
855889
| `mtu` | `uint16_t` | `1500` | Maximum transmission unit (fragments if larger) |
890+
| `log_level` | `LogLevel` | `UNKNOWN` (off) | Level to log the networking internals at |
856891

857892
When a new configuration is received, the `NetworkController` tears down existing sockets and reinitializes with the new settings.
858893
The node name becomes the identifier that other peers see in `NetworkJoin` events.
859894

895+
### Logging
896+
897+
Every component logs what it is doing through a single sink in `Log.hpp`, gated by a process wide log level that
898+
defaults to `Off`.
899+
Because the library is usable without the reactor framework, that sink is a callback rather than a hard dependency
900+
on NUClear's logging system:
901+
902+
```cpp
903+
using LogHandler = std::function<void(LogLevel level, const char* component, const std::string& message)>;
904+
NUClearNet::set_log_handler(handler); // an empty handler restores the stderr default
905+
```
906+
907+
Each embedder installs its own handler:
908+
909+
| Embedder | Where the messages go |
910+
| ------------------- | -------------------------------------------------------------------------- |
911+
| `NetworkController` | Re-emitted as NUClear `LogMessage`s, so the usual log handlers pick them up |
912+
| NUClearNet.js | Handed to the JavaScript logger alongside the binding's own messages |
913+
| Standalone | `std::cerr`, formatted as `[NUClearNet:<component>] <level> <message>` |
914+
915+
Under the reactor framework `NetworkConfiguration::log_level` drives both the library level (which decides what
916+
the library hands to the handler) and the `NetworkController` reactor level (which decides what it emits), so a
917+
single setting controls the whole path.
918+
860919
### Internal engine parameters
861920
862921
The `NUClearNet` engine supports additional parameters beyond what's exposed through `NetworkConfiguration`:

src/nuclear/docs/how-to/networking.md

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,44 @@ public:
5555
| `name` | `string` || Unique name for this node on the network |
5656
| `announce_address` | `string` | `"239.226.152.162"` | Address for node discovery announcements |
5757
| `announce_port` | `uint16_t` | `7447` | Port for announce messages |
58-
| `bind_address` | `string` | `""` (all) | Local interface to bind to |
58+
| `bind_address` | `string` | `""` (all) | Local interface to bind to (see [Forming a mesh](#forming-a-mesh) — default `INADDR_ANY` is required for broadcast fan-out on macOS) |
5959
| `mtu` | `uint16_t` | `1500` | Maximum transmission unit (fragments if larger) |
60+
| `log_level` | `LogLevel` | `UNKNOWN` (off) | Level to log the networking internals at (see [Logging](#logging)) |
61+
62+
### Logging
63+
64+
NUClearNet logs what it is doing internally — discovery, handshakes, fragmentation, retransmission.
65+
Set `log_level` on the `NetworkConfiguration` to turn it on:
66+
67+
```cpp
68+
emit(std::make_unique<NUClear::message::NetworkConfiguration>(
69+
"alice", // Node name
70+
"239.226.152.162", // Multicast announce address
71+
7447, // Announce port
72+
"", // Bind address
73+
1500, // MTU
74+
NUClear::LogLevel::DEBUG
75+
));
76+
```
77+
78+
This sets both the log level inside the NUClearNet library and the log level of the `NetworkController` reactor
79+
that emits the messages, so the two cannot disagree.
80+
The messages come out through the normal NUClear logging system as `LogMessage`s, so your existing log handlers
81+
see them alongside everything else.
82+
Leaving `log_level` as `UNKNOWN` disables the networking logs entirely, which is the default.
83+
84+
When NUClearNet is used as a standalone library (without the reactor framework) the messages are written to
85+
stderr instead. Call `NUClearNet::set_log_handler` to redirect them into your own logging system:
86+
87+
```cpp
88+
NUClear::network::NUClearNet::set_log_level(NUClear::network::LogLevel::Debug);
89+
NUClear::network::NUClearNet::set_log_handler(
90+
[](NUClear::network::LogLevel level, const char* component, const std::string& message) {
91+
my_logger.write(level, component, message);
92+
});
93+
```
94+
95+
Pass an empty handler to go back to the stderr default.
6096
6197
### Network modes
6298
@@ -67,6 +103,8 @@ NUClearNet supports several discovery modes depending on the `announce_address`
67103
| **Multicast IPv4** | `239.x.x.x` | `239.226.152.162` | LAN discovery, multiple nodes |
68104
| **Multicast IPv6** | `ff02::x` | `ff02::1` | IPv6 LAN discovery |
69105
| **Broadcast IPv4** | `x.x.x.255` | `192.168.1.255` | Simple LAN, all nodes on subnet |
106+
| **Loopback broadcast** | `127.255.255.255` | `127.255.255.255` | Local dev on Linux only (see [Forming a mesh](#forming-a-mesh)) |
107+
| **Loopback unicast** | `127.0.0.1` | `127.0.0.1` | Point-to-point between two nodes (not multi-peer on shared port) |
70108
| **Unicast IPv4/IPv6** | Specific IP | `192.168.1.50` | Point-to-point, two nodes |
71109
72110
#### Multicast (Default)
@@ -109,6 +147,63 @@ emit(std::make_unique<NUClear::message::NetworkConfiguration>(
109147

110148
In unicast mode, each peer announces directly to the other.
111149
This is useful when multicast/broadcast is unavailable (e.g., across subnets or VPNs).
150+
Unicast does **not** fan out to every socket bound on the shared announce port, so it cannot form a multi-peer mesh on one host.
151+
152+
### Forming a mesh
153+
154+
A mesh forms when all nodes share the same `announce_address` and `announce_port`.
155+
Each node periodically sends discovery packets to that address; every peer that receives them can discover the others and complete a CONNECT handshake.
156+
157+
For multi-peer discovery on one machine, announce traffic must reach **every** process bound to the shared announce port.
158+
Which addresses satisfy that depends on the announce address and OS stack, not on socket option policy.
159+
160+
#### Socket binding
161+
162+
By default, NUClearNet binds the announce socket to **all interfaces** (`INADDR_ANY`), regardless of the announce address.
163+
This default is required for broadcast fan-out on macOS.
164+
Setting `bind_address` to a specific interface IP can prevent broadcast reception on macOS — leave it empty unless you have a specific reason to bind to one interface.
165+
166+
#### Reuse options
167+
168+
Multiple processes on one host must bind the same UDP announce port.
169+
NUClearNet sets **`SO_REUSEADDR`** on all platforms and **`SO_REUSEPORT`** when the platform provides it — the two options are always paired where `SO_REUSEPORT` exists.
170+
Socket setup is consistent everywhere; what varies is which announce addresses fan out to every bound socket vs one socket.
171+
172+
#### Valid announce addresses
173+
174+
| Address | Linux | macOS |
175+
| ------- | ----- | ----- |
176+
| `239.226.152.162` (multicast, default) | Valid — all sockets join the group | Valid — recommended |
177+
| `127.255.255.255` (loopback broadcast) | Valid — recommended for local dev | **Invalid** — macOS does not deliver UDP to this address locally (stack limitation) |
178+
| `192.168.x.255` (subnet broadcast) | Valid | Valid — requires default `INADDR_ANY` bind |
179+
| `255.255.255.255` (global broadcast) | Valid — noisy | Valid — requires default `INADDR_ANY` bind |
180+
| `127.0.0.1` or specific IP (unicast) | **Invalid** — one bound socket | **Invalid** — load-balanced to one bound socket |
181+
182+
```cpp
183+
// Linux local dev — loopback broadcast fans out to every peer
184+
emit(std::make_unique<NUClear::message::NetworkConfiguration>(
185+
"my-node", "127.255.255.255", 7447));
186+
187+
// macOS local dev — use the default multicast address
188+
emit(std::make_unique<NUClear::message::NetworkConfiguration>(
189+
"my-node", "239.226.152.162", 7447));
190+
```
191+
192+
#### Cross-platform summary
193+
194+
| Scenario | Recommended address |
195+
| -------- | ------------------- |
196+
| LAN, multiple machines | `239.226.152.162` (multicast) or subnet broadcast |
197+
| Local dev, Linux | `127.255.255.255` (loopback broadcast) or multicast |
198+
| Local dev, macOS | `239.226.152.162` (multicast) |
199+
| Two known peers, point-to-point | Unicast to each other's IP (not multi-peer on shared port) |
200+
201+
#### Loopback (local development)
202+
203+
When running multiple NUClearNet processes on one machine, pick an announce address that fans out to every listener on the shared port (see tables above).
204+
205+
On Linux, `127.255.255.255` is the simplest local-dev choice when you want to avoid multicast.
206+
On macOS, use the default multicast address — loopback broadcast is not delivered locally.
112207
113208
## 2. Send Messages
114209

0 commit comments

Comments
 (0)