Skip to content

RealRaceCoreEnv.close() leaves drone armed and radio link open when connected but never took off #85

@Yuming-Lee24

Description

@Yuming-Lee24

Bug: RealRaceCoreEnv.close() leaves drone armed and radio link open when connected but never took off

File: lsy_drone_racing/envs/real_race_env.py (lines 445–468)
Severity: Medium — no immediate physical danger, but leaves the drone in an unsafe state and breaks subsequent deploy attempts.
Status: Open

Summary

RealRaceCoreEnv.close() documents that "the drone will be stopped immediately afterwards or in case of errors", but a code path skips both the emergency-stop packet and self.drone.close_link() when the drone has connected but never taken off. As a result the drone stays armed and the radio link stays open after close() returns, contradicting the docstring.

Code

def close(self):
    """Close the environment.

    If the drone has finished the track, it will try to return to the start position.
    Irrespective of succeeding or not, the drone will be stopped immediately afterwards or in
    case of errors, and close the connections to the ROSConnector.
    """
    if not self.data.drone_connected or not self.data.taken_off:
        self._ros_connector.close()
        return                                # ← early return
    try:
        self._return_to_start()
    finally:
        try:
            # Kill the drone (emergency stop)
            pk = CRTPPacket()
            pk.port = CRTPPort.LOCALIZATION
            pk.channel = Localization.GENERIC_CH
            pk.data = struct.pack("<B", Localization.EMERGENCY_STOP)
            self.drone.send_packet(pk)
            self.drone.close_link()          # ← only reached if taken_off
        finally:
            self._ros_connector.close()

Behavior matrix

drone_connected taken_off Path taken Disarm? close_link()? ROS close? Correct?
False False early return n/a n/a
False True early return n/a n/a
True False early return bug
True True full cleanup

The third row is the bug. After _connect_radio succeeds, _reset_drone arms the drone and initializes the Kalman filter. If the script aborts before takeoff (controller crash, user Ctrl-C, failed pre-flight check, exception in controller.compute_control, etc.) and close() is called, the early return runs.

Reproduction

import gymnasium, rclpy
from lsy_drone_racing.utils import load_config

rclpy.init()
config = load_config("config/level2.toml")
env = gymnasium.make("RealDroneRacing-v0", drones=config.deploy.drones, ...)
env.reset(options=config.deploy)   # connects + arms; sets drone_connected=True
                                   # never takes off → taken_off stays False
env.close()                        # early-return path, leaves drone armed

After this, the Crazyflie remains armed and env.unwrapped.drone still holds an open radio link.

Expected behavior

Per the docstring, whenever drone_connected=True, close() should always:

  1. Send the EMERGENCY_STOP packet (drone disarms).
  2. Call self.drone.close_link() (radio link closed).
  3. Close the ROS connector.

Whether _return_to_start() runs is a separate question — it only makes sense if the drone took off.

Actual behavior

When drone_connected=True and taken_off=False, only step 3 runs. Steps 1 and 2 are skipped because the early return short-circuits the function.

Impact

  • Drone stays armed. Any stray setpoint (from a leftover process, a ROS topic, etc.) can spin the motors. The drone won't fly on its own, but it isn't safe to handle.
  • Radio link stays open. Re-running the deploy script will fail at cflib's open_link, since the URI is already in use. Users typically work around this by power-cycling the dongle or rebooting.
  • Silent. No log message indicates the abnormal shutdown — the caller sees a clean return.

Suggested fix

Restructure so the emergency-stop and close_link() always run when drone_connected=True, while _return_to_start() only runs when taken_off=True:

def close(self):
    if not self.data.drone_connected:           # nothing to clean up
        self._ros_connector.close()
        return
    try:
        if self.data.taken_off:                 # only fly back if we took off
            self._return_to_start()
    finally:
        try:
            pk = CRTPPacket()
            pk.port = CRTPPort.LOCALIZATION
            pk.channel = Localization.GENERIC_CH
            pk.data = struct.pack("<B", Localization.EMERGENCY_STOP)
            self.drone.send_packet(pk)
            self.drone.close_link()
        finally:
            self._ros_connector.close()

This preserves the existing happy path while making the abort-before-takeoff path clean.

Notes

  • Found while writing hardware tests for RealRaceCoreEnv methods (specifically _connect_radio). The test cleanup has to call drone.close_link() directly because env.close() can't be relied on for the not-taken-off path.
  • The DeMorgan-equivalent condition if not (drone_connected and taken_off) produces the same buggy behavior — the issue is the conflation of two states, not the operator choice.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions