Skip to content

bc_stark_sdk 2.0.2: stark_node leaks memory to OOM-kill within ~10-30s of dual-hand operation (reproducible 7/7) #3

Description

@AnnaKn0rr

Environment

  • Robot: Unitree G1 (dual Revo2 Touch, capacitive, connected via dual RS-485 / dual USB-serial)
  • SDK: bc_stark_sdk / libbc_stark_sdk.so v2.0.2 (linux ARM64)
  • ROS2: Foxy, ros2_stark_controller package (based on this repo's ros2_stark_ws)
  • Left hand: SN BCXTL2361J250002F, FW 1.0.16.U
  • Right hand: SN BCXTR2361J250001A, FW 1.0.16.U
  • Protocol: Modbus RTU, baud 460800

Symptom

stark_node process RSS grows to ~2GB+ within roughly 10-30 seconds of starting, and is killed (either by the kernel OOM killer or a cgroup memory cap we added as a mitigation). Reproduced on 7 consecutive runs under systemd with Restart=always, every single one died the same way.

Kernel OOM killer log from one occurrence (no memory cap in place at the time):

dockerd invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=-500
oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/system.slice/stark_node.service,task=stark_node,pid=1854,uid=1000
Out of memory: Killed process 1854 (stark_node) total-vm:31320256kB, anon-rss:13234216kB, file-rss:0kB, shmem-rss:16kB, UID:1000 pgtables:41340kB oom_score_adj:0
oom_reaper: reaped process 1854 (stark_node), now anon-rss:0kB, file-rss:0kB, shmem-rss:24kB

~13.2GB resident in well under a minute of runtime. This is severe enough that it starved unrelated processes on the machine (note dockerd is what invoked the OOM killer here, not our process).

Key evidence this is in the SDK, not our integration code

In one run, bad_alloc was thrown three times before our first log line printed before modbus_open() was ever called, before our ROS2 poll timer (50ms) was even constructed:

[INFO] [stark_node-1]: process started with pid [3158]
[stark_node-1] bad_alloc caught: std::bad_alloc
[stark_node-1] bad_alloc caught: std::bad_alloc
[stark_node-1] bad_alloc caught: std::bad_alloc
... (dies ~12s later, exit code -9)

Since nothing in our code has executed yet at that point, this rules out a leak in the ROS2 node integration and points at SDK-internal state (possibly a background thread spawned on library load or modbus_open).

We also see it happen after a fully clean initialization (no read timeouts, both hands report device info successfully, touch sensors enabled on both):

[LEFT] SN=BCXTL2361J250002F  FW=1.0.16.U  HW=11
[LEFT] Touch sensors enabled — waiting 1 s…
touch_sensor_setup: slave_id=126, bits=0b11111
touch_sensor_enable: slave_id=126, address=0x0FA0, bits=0b11111, values=[1, 1, 1, 1, 1]
[RIGHT] SN=BCXTR2361J250001A  FW=1.0.16.U  HW=11
[RIGHT] Touch sensors enabled — waiting 1 s…
touch_sensor_setup: slave_id=127, bits=0b11111
touch_sensor_enable: slave_id=127, address=0x0FA0, bits=0b11111, values=[1, 1, 1, 1, 1]
... (dies ~11s later, exit code -9)

So the leak isn't tied to the read-timeout/retry path either, it also happens in steady-state polling after a clean startup.

Our integration code (for reference, every returned pointer is freed)

void publish_motor(DeviceHandler* handle, uint8_t slave_id,
                   rclcpp::Publisher<ros2_stark_interfaces::msg::MotorStatus>::SharedPtr& pub,
                   const char* label) {
    auto* data = stark_get_motor_status(handle, slave_id);
    if (!data) { /* ... */ return; }
    auto msg = ros2_stark_interfaces::msg::MotorStatus();
    std::copy(data->positions, data->positions + 6, msg.positions.begin());
    std::copy(data->speeds,    data->speeds    + 6, msg.speeds.begin());
    std::copy(data->currents,  data->currents  + 6, msg.currents.begin());
    std::copy(data->states,    data->states    + 6, msg.states.begin());
    pub->publish(msg);
    free_motor_status_data(data);
}

void publish_touch(DeviceHandler* handle, uint8_t slave_id,
                   rclcpp::Publisher<ros2_stark_interfaces::msg::TouchStatus>::SharedPtr& pub,
                   const char* label) {
    auto* data = stark_get_touch_status(handle, slave_id);
    if (!data) { /* ... */ return; }
    auto msg = ros2_stark_interfaces::msg::TouchStatus();
    for (int i = 0; i < 5; ++i) { /* copy fields */ }
    pub->publish(msg);
    free_touch_finger_data(data);
}

Both are called from a 50ms wall_timer, alongside stark_get_voltage on a 30s timer (returns a scalar, nothing to free).

Repro steps

  1. Dual-hand Modbus RTU setup, baud 460800, one hand per USB-serial adapter.
  2. Call stark_set_hardware_type, stark_get_device_info, stark_enable_touch_sensor(handle, slave_id, 0x1F) on each hand at startup.
  3. Poll stark_get_motor_status + stark_get_touch_status every 50ms per hand, freeing each returned pointer after use.
  4. Observe process RSS via systemctl status <service> or ps grows unbounded within seconds, process is killed within 10-30s.

Questions

  1. Is there a newer SDK build past v2.0.2 (linux ARM64) that addresses this?
  2. Does bc_stark_sdk spawn any internal background threads on modbus_open() or library load that could account for allocation happening before the caller's own API calls return?
  3. Is there a known workaround (e.g. a required cleanup/flush call we're missing, or a known-bad code path when both hands share one process)?

Happy to provide valgrind/heaptrack output against stark_node if that would help narrow down the allocation site.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions