Skip to content

Make CAN receive interruptible and bounded - #6

Merged
justagist merged 7 commits into
mainfrom
receive-path-shutdown
Oct 15, 2026
Merged

justagist merged 7 commits into
mainfrom
receive-path-shutdown

Conversation

@justagist

@justagist justagist commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Fixes the transport-level issues from the review: shutdown could hang forever on a silent
bus, the read loop capped throughput at about one frame per millisecond, and a failed
init() left a half-open socket that a later init() reported as open.

  • StdCanInterface::readCanMessage() now poll()s on the socket and an eventfd, so
    interruptRead() unblocks it from any thread. disconnectPort() uses that before joining.
  • The read loop no longer sleeps 1 ms per frame; it blocks until traffic arrives.
  • init() builds the socket in a local fd and only commits it on success. The class is
    non-copyable.
  • A constructor adopting an open fd, plus one on PiperInterfaceV2 adopting a transport,
    let the tests drive everything through a socketpair with no hardware.
  • First unit test target (GTest, BUILD_TESTING, 30 s timeout per test): six tests covering
    the silent-bus shutdown, interrupt semantics, a 3000-frame burst reaching the state
    cache, ordering under buffer saturation, and init retry.

Nothing on the wire or in the decoders changes. On hardware the visible differences are
lower receive latency and CPU, and a prompt disconnect with the arm powered off.

disconnectPort(timeout) keeps its parameter for API compatibility; it is now unused.
Short-read validation of recvmsg is left for the frame-validation PR.

Summary by Sourcery

Make CAN reception interruptible, promptly stoppable, and capable of draining traffic without artificial delays while adding regression coverage.

New Features:

  • Add interruptible CAN reads and injectable transport constructors for hardware-independent testing.

Bug Fixes:

  • Ensure disconnects promptly on silent buses and failed CAN initialization can be retried cleanly.
  • Prevent receive-loop throughput loss caused by per-frame sleeping and close dead sockets rather than spinning.

Enhancements:

  • Bound blocking reads with socket and wake-event polling while preserving existing wire and decoder behavior.

Build:

  • Add optional CMake and GTest unit-test targets with documented test commands and dependencies.

Documentation:

  • Document unit-test prerequisites and execution.

Tests:

  • Add socketpair-based coverage for read interruption, shutdown, burst throughput, frame ordering, malformed records, peer closure, and initialization retry.

Chores:

  • Make the CAN interface non-copyable to enforce file-descriptor ownership.

@justagist
justagist requested a balanced review from Copilot October 15, 2026 09:16
@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e174d861-6c59-4e87-872c-e2accef2955c
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai

sourcery-ai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR makes SocketCAN receive shutdown-safe by polling the socket with an eventfd wake mechanism, removes the read-loop throttle, and makes initialization transactional. It adds ownership-safe dependency injection for socketpair testing, regression tests for interruption, shutdown, throughput, ordering, and retry behavior, plus build and usage documentation.

Sequence diagram for interruptible CAN shutdown

sequenceDiagram
    participant Piper as PiperInterfaceV2
    participant Reader as readLoop
    participant CAN as StdCanInterface
    participant Wake as eventfd

    Piper->>Reader: stop_read_.store(true)
    Piper->>CAN: interruptRead()
    CAN->>Wake: eventfd_write()
    Reader->>CAN: readCanMessage()
    CAN->>Wake: poll()
    Wake-->>CAN: wake event
    CAN-->>Reader: false
    Reader-->>Piper: thread exits
    Piper->>Reader: join()
Loading

Sequence diagram for bounded CAN initialization retry

sequenceDiagram
    participant Client as Caller
    participant CAN as StdCanInterface
    participant Kernel as SocketCAN

    Client->>CAN: init()
    CAN->>Kernel: socket()
    Kernel-->>CAN: fd
    CAN->>Kernel: ioctl()
    Kernel-->>CAN: failure
    CAN->>Kernel: close(fd)
    CAN-->>Client: false
    Client->>CAN: init()
    CAN->>Kernel: socket()
    Kernel-->>CAN: new fd
    CAN->>Kernel: bind()
    Kernel-->>CAN: success
    CAN-->>Client: true
Loading

File-Level Changes

Change Details Files
Make blocking CAN reads interruptible and remove the per-frame polling delay.
  • Add an eventfd wake channel and poll the CAN socket plus wake fd.
  • Expose interruptRead() and use it during disconnect before joining the reader thread.
  • Let the read loop block for traffic and drain frames without a fixed sleep.
piper_cpp/include/piper_cpp/std_can_interface.h
piper_cpp/src/interface/piper_interface_v2.cpp
piper_cpp/include/piper_cpp/interface/piper_interface_v2.h
Make socket ownership and initialization failure-safe.
  • Build and bind initialization with a local descriptor, closing it on failure before committing it.
  • Add ownership-taking construction for existing descriptors and disable copying.
  • Close the wake descriptor during destruction.
piper_cpp/include/piper_cpp/std_can_interface.h
Add dependency-injected construction and socketpair-based transport tests.
  • Allow PiperInterfaceV2 to adopt a StdCanInterface and install its frame parser callback.
  • Add GTest targets with per-test 30-second timeouts.
  • Cover interrupt behavior, silent shutdown, burst throughput, ordering under backpressure, and initialization retry.
piper_cpp/include/piper_cpp/interface/piper_interface_v2.h
piper_cpp/src/interface/piper_interface_v2.cpp
piper_cpp/test/CMakeLists.txt
piper_cpp/test/std_can_interface_test.cpp
piper_cpp/test/piper_interface_v2_test.cpp
Document test configuration and execution.
  • Document the GTest package requirement and BUILD_TESTING opt-out.
  • Add ctest and colcon test instructions.
piper_cpp/CMakeLists.txt
piper_cpp/README.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Constructor failures leak eventfds, and test linking is incompatible with supported older CMake versions.

2 open findings
What changed in this PR

Makes CAN reception interruptible for prompt shutdown and removes the per-frame delay to improve throughput.

Changes:

  • Adds eventfd-based wakeups and retry-safe socket initialization.
  • Adds transport-adopting constructors for hardware-free tests.
  • Introduces six regression tests and documents test setup.
File Description
piper_cpp/​test/​std_can_interface_test.cpp Tests interrupts, ordering, and initialization retries.
piper_cpp/​test/​piper_interface_v2_test.cpp Tests shutdown and burst processing.
piper_cpp/​test/​CMakeLists.txt Registers GTest tests with timeouts.
piper_cpp/​src/​interface/​piper_interface_v2.cpp Interrupts shutdown reads and removes receive delays.
piper_cpp/​README.md Documents test dependencies and commands.
piper_cpp/​include/​piper_cpp/​std_can_interface.h Adds interruptible reads and safer socket ownership.
piper_cpp/​include/​piper_cpp/​interface/​piper_interface_v2.h Declares transport adoption and documents shutdown behavior.
piper_cpp/​CMakeLists.txt Enables optional unit-test builds.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread piper_cpp/test/CMakeLists.txt Outdated
Comment thread piper_cpp/include/piper_cpp/std_can_interface.h Outdated

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 10 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="piper_cpp/include/piper_cpp/std_can_interface.h" line_range="188" />
<code_context>

+    /// Wake a thread blocked in `readCanMessage()`, which then returns false. Safe to call from
+    /// any thread. If no read is in progress, the next `readCanMessage()` returns false once.
+    void interruptRead() { eventfd_write(wake_fd_, 1); }
+
+    /// Replace the receive callback. Not synchronised with `readCanMessage()`; set it before
</code_context>
<issue_to_address>
**Shutdown can hang indefinitely**

When a signal interrupts `eventfd_write()` during shutdown, `interruptRead()` ignores the failed write, so the blocked reader receives no wake signal and `disconnectPort()` waits indefinitely in `join()`.

Retry `eventfd_write()` when it fails with `EINTR`, and handle any other write failure so the reader is not left blocked.
</issue_to_address>

### Comment 2
<location path="piper_cpp/src/interface/piper_interface_v2.cpp" line_range="98" />
<code_context>
-            // The callback will handle parsing and updating state
-        }
-        std::this_thread::sleep_for(std::chrono::milliseconds(1)); // TODO: Adjust sleep duration as needed
+        arm_can_->readCanMessage();
     }
 }
</code_context>
<issue_to_address>
**Persistent receive errors spin CPU**

When the CAN socket repeatedly reports a poll or receive error, or reports `POLLHUP`, `readCanMessage()` returns false on receive errors, but `readLoop()` immediately calls it again, consuming CPU while the failure persists. On EOF, `readCanMessage()` passes an uninitialized frame to the callback.

Handle failed reads and EOF before retrying: do not pass an invalid frame to the callback, and exit or pause the loop on persistent errors.
</issue_to_address>

### Comment 3
<location path="piper_cpp/include/piper_cpp/std_can_interface.h" line_range="36" />
<code_context>
-        : channel_name_(std::move(channel_name)), expected_bitrate_(expected_bitrate), callback_(std::move(callback)),
-          sock_(-1)
+        : channel_name_(std::move(channel_name)), expected_bitrate_(expected_bitrate), sock_(-1),
+          wake_fd_(makeWakeFd()), callback_(std::move(callback))
     {
         if (judge_flag)
</code_context>
<issue_to_address>
**Failed construction leaks descriptors**

When CAN validation throws during construction, or wake-FD creation fails in the adopting constructor, the normal constructor creates `wake_fd_` before `judgeCanInfo()` can throw, and the destructor does not run when construction fails, leaking the eventfd on each retry. In the adopting constructor, a failure to create the wake FD also leaves the adopted socket open.

Manage the wake FD and adopted socket with RAII so both are closed if construction fails.

Also at `piper_cpp/include/piper_cpp/std_can_interface.h:47`.
</issue_to_address>

### Comment 4
<location path="piper_cpp/include/piper_cpp/interface/piper_interface_v2.h" line_range="78" />
<code_context>
-    /// Idempotent.
+    /// Stop the background read thread (if running) and close the CAN port. Wakes the read
+    /// thread out of its blocking receive and joins it, so this returns promptly even on a
+    /// silent bus. `timeout` is currently unused and kept for API compatibility. Idempotent.
     void disconnectPort(std::chrono::milliseconds timeout = std::chrono::milliseconds{100});

</code_context>
<issue_to_address>
**Disconnect exceeds its timeout**

When the read thread does not finish within the caller’s requested timeout, `disconnectPort()` unconditionally joins the read thread, so the call blocks beyond `timeout`; callers such as ROS cleanup lose the requested time bound.

Make `disconnectPort()` honor the requested timeout when waiting for the read thread.

Also at `piper_cpp/include/piper_cpp/interface/piper_interface_v2.h:80`.
</issue_to_address>

### Comment 5
<location path="piper_cpp/src/interface/piper_interface_v2.cpp" line_range="51-52" />
<code_context>
+)
+    : PiperInterfaceV2("", /*judge_flag=*/false, /*auto_init=*/false, dh_offset, sdk_joint_limit, sdk_gripper_limit)
+{
+    arm_can_ = std::move(can);
+    arm_can_->setCallback([this](const struct can_frame &frame, double timestamp) { parseCANFrame(frame, timestamp); });
+}
+
</code_context>
<issue_to_address>
**Null transport crashes construction**

When a caller passes an empty `std::unique_ptr<StdCanInterface>`, `arm_can_` receives the moved `can` pointer and `setCallback()` dereferences it, so passing a null `unique_ptr` crashes instead of producing a clear construction error.

Reject a null transport with a documented precondition or exception before dereferencing it.
</issue_to_address>

### Comment 6
<location path="piper_cpp/include/piper_cpp/std_can_interface.h" line_range="47" />
<code_context>
+    /// Adopt an already-open socket that delivers one `can_frame` per datagram (a raw CAN socket,
+    /// or a socketpair in tests). Takes ownership of `socket_fd`; `init()` is a no-op.
+    explicit StdCanInterface(int socket_fd, CanCallback callback = nullptr)
+        : channel_name_(), expected_bitrate_(0), sock_(socket_fd), wake_fd_(makeWakeFd()),
+          callback_(std::move(callback))
+    {
</code_context>
<issue_to_address>
**Adopted transports cannot reconnect**

When an interface constructed with the fd-adopting overload is disconnected and then reconnected, after `disconnectPort()` closes the adopted fd, the next `connectPort()` calls `init()`. Since `sock_` is now `-1`, `init()` opens a new CAN socket and looks up the empty `channel_name_`, so it fails and callers cannot reconnect this interface.

Preserve enough information to reopen the adopted transport, or explicitly make the adopted interface one-shot and prevent reconnect attempts.
</issue_to_address>

### Comment 7
<location path="piper_cpp/include/piper_cpp/std_can_interface.h" line_range="47" />
<code_context>
+    /// Adopt an already-open socket that delivers one `can_frame` per datagram (a raw CAN socket,
+    /// or a socketpair in tests). Takes ownership of `socket_fd`; `init()` is a no-op.
+    explicit StdCanInterface(int socket_fd, CanCallback callback = nullptr)
+        : channel_name_(), expected_bitrate_(0), sock_(socket_fd), wake_fd_(makeWakeFd()),
+          callback_(std::move(callback))
+    {
</code_context>
<issue_to_address>
**Adopted sockets lose timestamps**

When a caller adopts an already-open raw CAN socket without `SO_TIMESTAMP` enabled, `readCanMessage()` leaves `timestamp` at 0.0 when an adopted raw CAN socket has not enabled `SO_TIMESTAMP`; the regular `init()` path enables that option, but the adopting constructor skips it. `PiperInterfaceV2` then publishes epoch-zero timestamps in its state snapshots.

Enable or verify `SO_TIMESTAMP` for adopted raw CAN sockets, or define a valid fallback timestamp.
</issue_to_address>

### Comment 8
<location path="piper_cpp/test/CMakeLists.txt" line_range="7" />
<code_context>
+# A regression in the shutdown path shows up as a hang, so bound every test.
+function(add_piper_test name)
+    add_executable(${name} ${name}.cpp)
+    target_link_libraries(${name} PRIVATE piper_cpp GTest::gtest_main)
+    gtest_discover_tests(${name} PROPERTIES TIMEOUT 30)
+endfunction()
</code_context>
<issue_to_address>
**Tests fail with older CMake**

When CMake 3.10–3.19 uses `FindGTest` and the installed package does not define `GTest::gtest_main`, `target_link_libraries()` names `GTest::gtest_main`, which the older module does not provide, so test configuration or generation fails and the tests cannot build.

Link against a GTest target available from the older module, or select the target based on what the installed GTest package provides.
</issue_to_address>

### Comment 9
<location path="piper_cpp/test/CMakeLists.txt" line_range="1" />
<code_context>
+find_package(GTest REQUIRED)
+include(GoogleTest)
+
</code_context>
<issue_to_address>
**Default build requires GTest**

When a normal build environment lacks GTest, cMake configuration fails at `find_package(GTest REQUIRED)` when GTest is absent, so building the library and executables now requires a test-only development dependency unless every such build explicitly disables testing.

Make tests opt-in or discover GTest quietly and skip adding test targets when it is unavailable.
</issue_to_address>

### Comment 10
<location path="piper_cpp/test/std_can_interface_test.cpp" line_range="124" />
<code_context>
+    // No such interface, so init() fails at the ifindex lookup. Before the fix the half-open
+    // socket was kept and a second init() reported success.
+    StdCanInterface can("piper_cpp_no_such_iface", 1000000, /*judge_flag=*/false, /*auto_init=*/true);
+    EXPECT_FALSE(can.init());
+}
</code_context>
<issue_to_address>
**Initialization regression goes undetected**

When a future change reintroduces the half-open-socket behavior, or the test runs without permission to create a raw CAN socket, the test makes only one explicit `init()` call and does not establish that it reached the invalid-interface lookup. It can therefore pass without detecting the regression, including when socket creation itself fails.

Ensure the test reaches the invalid-interface lookup, then call `init()` again and verify that the failed socket was not retained.
</issue_to_address>

Sourcery assessment

Approval pending. 10 findings to address first.

Blocking findings: piper_cpp/include/piper_cpp/std_can_interface.h:188, piper_cpp/src/interface/piper_interface_v2.cpp:98, piper_cpp/include/piper_cpp/std_can_interface.h:36, piper_cpp/include/piper_cpp/interface/piper_interface_v2.h:78, piper_cpp/src/interface/piper_interface_v2.cpp:52, and 5 more


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread piper_cpp/include/piper_cpp/std_can_interface.h
Comment thread piper_cpp/src/interface/piper_interface_v2.cpp
Comment thread piper_cpp/include/piper_cpp/std_can_interface.h Outdated
Comment thread piper_cpp/include/piper_cpp/interface/piper_interface_v2.h
Comment thread piper_cpp/src/interface/piper_interface_v2.cpp
Comment thread piper_cpp/include/piper_cpp/std_can_interface.h
Comment thread piper_cpp/include/piper_cpp/std_can_interface.h
Comment thread piper_cpp/test/CMakeLists.txt Outdated
Comment thread piper_cpp/test/CMakeLists.txt
Comment thread piper_cpp/test/std_can_interface_test.cpp
@justagist
justagist merged commit 25b36b9 into main Oct 15, 2026
5 checks passed
@justagist
justagist deleted the receive-path-shutdown branch October 19, 2026 08:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants