Skip to content

fix: replace socketcan with minimal native addon + fs streams - #401

Merged
sbender9 merged 1 commit into
canboat:masterfrom
dirkwa:replace-socketcan
Mar 23, 2026
Merged

sbender9 merged 1 commit into
canboat:masterfrom
dirkwa:replace-socketcan

Conversation

@dirkwa

@dirkwa dirkwa commented Mar 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the socketcan npm package with a minimal N-API native addon (~50 lines C++) that opens a PF_CAN socket and returns the raw fd. The fd is then read via fs.createReadStream (libuv threadpool) and written via fs.write, completely eliminating the uv_poll_t silent-stall failure mode.

Problem

Users on SocketCAN hardware (SailorHat, Pican-M, Waveshare CAN hats) experience intermittent silent N2K data stalls requiring server restart. The root cause is in the socketcan npm package:

  • It uses uv_poll_t to deliver CAN frames from the kernel to Node.js
  • Under GC pressure or event loop starvation, the poll handle stops being re-armed
  • The CAN socket fd remains alive and the kernel keeps receiving frames, but Node.js never calls recv() again — silent, permanent stall
  • candump can0 keeps working because it has its own separate fd
  • rawcan has the identical problem — also uses uv_poll_init_socket

Solution

The native addon does only what cannot be done in pure JS:

  1. socket(PF_CAN, SOCK_RAW, CAN_RAW) — create the socket
  2. bind() — bind to the CAN interface
  3. Return the raw fd

From there, fs.createReadStream reads CAN frames on libuv's threadpool (blocking reads on worker threads, not the event loop), and fs.write handles the send path. No uv_poll_t anywhere.

A CanChannel wrapper class provides the exact same API as socketcan's channel (onMessage, onStopped, start(), stop(), send()), keeping changes minimal across consumers.

Changes

  • New: native/canSocket.cpp — minimal N-API addon
  • New: lib/canSocket.tsCanChannel drop-in wrapper using fs streams
  • New: binding.gyp — node-gyp build config
  • Modified: lib/canbus.ts, lib/simpleCan.ts, lib/bin/candumpjs.ts, lib/bin/cansendjs.ts — switched from socketcan to CanChannel
  • Modified: package.json — removed socketcan from optionalDependencies, added node-addon-api, gypfile: true

Testing

  • Validated on vcan0 (virtual CAN) — read and write paths working
  • Validated with candumpjs on vcan0 — full PGN parsing pipeline confirmed
  • Tested on RPi4 with CAN hat on live N2K bus — continuous data flow, no stalls
  • All existing tests pass (38 jest + 186 mocha)

Why not net.Socket?

net.Socket({ fd }) was the original plan, but Node.js's uv_guess_handle() returns UV_UNKNOWN_HANDLE for CAN socket fds. fs.createReadStream uses the threadpool instead — equally reliable, no poll handles involved.

Refs: SignalK/signalk-server#1626, SignalK/signalk-server#2140

Replace the socketcan npm package (which uses uv_poll_t) with a minimal
N-API addon that opens a PF_CAN socket and returns the raw fd. The fd is
then read via fs.createReadStream (libuv threadpool) and written via
fs.write, eliminating the uv_poll_t silent-stall failure mode that causes
intermittent N2K data loss under GC pressure or event loop starvation.

The new CanChannel class is a drop-in replacement for socketcan's channel
API (onMessage, onStopped, start, stop, send), keeping changes minimal
across canbus.ts, simpleCan.ts, candumpjs.ts, and cansendjs.ts.

Refs: SignalK/signalk-server#1626, SignalK/signalk-server#2140
@dirkwa

dirkwa commented Mar 22, 2026

Copy link
Copy Markdown
Contributor Author

Testing on RPi4 with CAN hat

1. Clone and build on the Pi:

cd ~
git clone -b replace-socketcan https://github.com/dirkwa/canboatjs.git canboatjs-test
cd canboatjs-test
npm install
npm run build

The npm install will automatically run node-gyp rebuild (via the gypfile flag) to compile the native addon on the Pi's ARM architecture. Make sure build-essential and python3 are installed (sudo apt install build-essential python3).

2. Quick smoke test with candumpjs:

node dist/bin/candumpjs.js --format candump can0

You should see live N2K frames scrolling.

3. Full Signal K server test:

Point your Signal K server's canboatjs dependency at the local build. In your Signal K server directory:

npm install ~/canboatjs-test

Then restart the server and verify:

  • N2K data flows in the dashboard
  • No silent stalls over extended periods
  • Address claim works (check server logs for "Connected to CAN bus")
  • Reconnection works: sudo ip link set can0 down && sleep 5 && sudo ip link set can0 up

4. Stress test (the whole point):

node --expose-gc -e "
  setInterval(() => { global.gc(); }, 100);
  setTimeout(() => { console.log('GC stress test done'); process.exit(0); }, 300000);
" &
node dist/bin/candumpjs.js --format candump can0

This runs aggressive GC every 100ms for 5 minutes while candumpjs reads frames. With the old socketcan, this would stall. With the new fs.createReadStream approach, it should be rock solid.

@tkurki

tkurki commented Mar 22, 2026

Copy link
Copy Markdown
Collaborator

This sounds like a bug in socketcan. Has this been reported upstream?

@tkurki

tkurki commented Mar 22, 2026

Copy link
Copy Markdown
Collaborator

I don’t think direct replacement is necessarily the right strategy.

Would it make sense to extract this to a separate module and use the strategy the esbuild has for loading prebuilt binaries (all platforms’ specific modules as optional deps and use the one that gets installed)? Not an insignificant amount of work and a maintenance burden for sure.

Naturally the best would be to have this fixed upstream. Seems like it is easily reproducible?

@sbender9

Copy link
Copy Markdown
Member

I definitely like the idea of moving this to a new package.

@tkurki the long term plan is a change to nodejs which will make so no native code is needed at all.

@dirkwa

dirkwa commented Mar 22, 2026

Copy link
Copy Markdown
Contributor Author

@tkurki

Upstream fix in socketcan:
The stall is architectural, not a simple bug. socketcan uses uv_poll_t to bridge the kernel CAN fd into Node.js, and the poll handle can permanently stop being re-armed under GC pressure or event loop starvation. Fixing this in socketcan would mean rewriting their entire I/O layer — they'd essentially have to do what we're doing here (hand the fd to Node.js stream machinery). The project has had open issues about this for years with no movement. It's also not easily reproducible on demand — it's a race condition that shows up in the field after hours/days of running with plugins that create event loop pressure (signalk-rpi-monitor, signalk-notifications). Users have reported it consistently across #1626 and #2140 but "run this script and it stalls in 5 minutes" isn't really possible.

Separate module with prebuilt binaries (esbuild strategy):
This is a Linux-only socket family — AF_CAN only exists on Linux, and in practice only on ARM SBCs with CAN hats (RPi, SailorHat, etc). There are no other platforms to build for, so the esbuild approach of platform-specific optional deps would be overhead for a single target. The addon is ~50 lines of C++ and compiles in seconds on the Pi during npm install.

Longer term:
As Scott mentioned, the real endgame is landing AF_CAN support in Node.js core (targeting Node 25/26 LTS). Once that ships, the native addon disappears entirely and you'd just use net.createConnection({ canDevice: 'can0' }). This PR is the bridge that fixes the problem for users today.

Happy to move this into a separate package if you feel strongly about it, but given the addon is tiny, Linux-only, and hopefully temporary until Node.js core support lands, inlining it seemed like the lowest-maintenance path rather than maintaining a soon deprecated repo.

@dirkwa

dirkwa commented Mar 22, 2026

Copy link
Copy Markdown
Contributor Author

Note - This code surfaced a pre existing race condition.
PR SignalK/n2k-signalk#311

Full test setup:

cd ~
mkdir test-canboat-401
cd test-canboat-401
   
# Signalk Server master
git clone https://github.com/SignalK/signalk-server.git
cd signalk-server
npm install
npm run build:all
   
# Canboatjs pr 401
cd ..
git clone -b replace-socketcan https://github.com/dirkwa/canboatjs.git canboatjs-test 
cd canboatjs-test 
npm install 
npm run build
   
# verify that we can read can0
node dist/bin/candumpjs.js --format candump can0
   
# when you see candump style data than CTRL-C and continue next step
cd ..
cd signalk-server
npm install ../canboatjs-test
   
# Fix race condition in n2k-signalk
cd ~/test-canboat-401
git clone -b fix-requestmetadata-guard https://github.com/dirkwa/n2k-signalk.git n2k-signalk-test
cd n2k-signalk-test
npm install
npm run build
cd ../signalk-server
npm install ../n2k-signalk-test

# start signalk, configure can0 in webui when needed
# I expect your signalk config in default path ~/.signalk
npm start

@sbender9
sbender9 merged commit 22e8f73 into canboat:master Mar 23, 2026
5 of 6 checks passed
sbender9 pushed a commit that referenced this pull request Apr 25, 2026
…ds (#416)

The fs.createReadStream-based read path introduced in #401 used a libuv
threadpool worker doing a blocking read() on the CAN socket. On Linux,
close() on a fd does NOT interrupt a read() blocked on that fd in
another thread, and shutdown() returns EOPNOTSUPP on PF_CAN/SOCK_RAW
(.shutdown = sock_no_shutdown in net/can/raw.c through v6.18). As a
result process.exit() hung indefinitely waiting to join the blocked
worker — see SignalK/signalk-server#2618. PR #415's shutdown() approach
silently fails for the same kernel reason.

Switch to the original architecture: open both read and write sockets
in non-blocking mode, register a uv_poll_t watcher on the read fd, and
drain all available frames via a non-blocking native read when the
watcher fires. Neither direction touches the libuv threadpool, so
process.exit() terminates cleanly even on a quiet bus.

This also restores the original rationale for #405 — the event-loop
starvation under load from issue #1626 was the *write* path blocking
threadpool workers (fixed by the native non-blocking writeCanFrame),
not the read path. The threadpool-read in #401 was layered on top
without being needed.

Verified on vcan0: exit completes immediately, frame send/receive
round-trips at the same per-burst capacity as master (~222 frames
before kernel SO_RCVBUF saturates, identical to pre-existing behavior).

Closes #415 (its diagnosis is correct but the shutdown() fix doesn't
work on Linux CAN sockets).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants