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:
- Send the
EMERGENCY_STOP packet (drone disarms).
- Call
self.drone.close_link() (radio link closed).
- 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.
Bug:
RealRaceCoreEnv.close()leaves drone armed and radio link open when connected but never took offFile:
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 andself.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 afterclose()returns, contradicting the docstring.Code
Behavior matrix
drone_connectedtaken_offclose_link()?FalseFalseFalseTrueTrueFalseTrueTrueThe third row is the bug. After
_connect_radiosucceeds,_reset_dronearms the drone and initializes the Kalman filter. If the script aborts before takeoff (controller crash, user Ctrl-C, failed pre-flight check, exception incontroller.compute_control, etc.) andclose()is called, the early return runs.Reproduction
After this, the Crazyflie remains armed and
env.unwrapped.dronestill holds an open radio link.Expected behavior
Per the docstring, whenever
drone_connected=True,close()should always:EMERGENCY_STOPpacket (drone disarms).self.drone.close_link()(radio link closed).Whether
_return_to_start()runs is a separate question — it only makes sense if the drone took off.Actual behavior
When
drone_connected=Trueandtaken_off=False, only step 3 runs. Steps 1 and 2 are skipped because the early return short-circuits the function.Impact
cflib'sopen_link, since the URI is already in use. Users typically work around this by power-cycling the dongle or rebooting.Suggested fix
Restructure so the emergency-stop and
close_link()always run whendrone_connected=True, while_return_to_start()only runs whentaken_off=True:This preserves the existing happy path while making the abort-before-takeoff path clean.
Notes
RealRaceCoreEnvmethods (specifically_connect_radio). The test cleanup has to calldrone.close_link()directly becauseenv.close()can't be relied on for the not-taken-off path.if not (drone_connected and taken_off)produces the same buggy behavior — the issue is the conflation of two states, not the operator choice.