Skip to content

fix(panda): stop deadlocking on the GIL and stalling control with get_log - #65

Merged
JeanElsner merged 1 commit into
mainfrom
jean/fix/control-thread-gil
Oct 1, 2026
Merged

JeanElsner merged 1 commit into
mainfrom
jean/fix/control-thread-gil

Conversation

@JeanElsner

@JeanElsner JeanElsner commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Summary

Two bugs in released 1.1.0, found together on an FER. A script held position for 60 s with logging enabled, then called get_log() and stop_controller(). The motors released and the script hung for good.

  • stop_controller() deadlocks. It joins the control thread while holding the GIL, and the control thread takes the GIL to log, for example when its loop ends with an error. If that happens during the stop, the main thread sits in std::thread::join and the control thread in take_gil, as a backtrace of the hung process showed. The Panda destructor takes the same path.
  • get_log() stalls the 1 kHz loop. It copies the whole log under the same mutex the control thread takes for every state. With a 60 s log, the copy took long enough for the robot to abort the motion, which is what ended the loop above.

Changes

  • stopController() releases the GIL around the join when it holds it, which covers both the binding and the destructor.
  • The log has its own mutex. get_log, enable_logging and disable_logging take it; _setState in the control loop only try-locks it. A sample that arrives during a read isn't logged, instead of the loop waiting.

Test plan

There's no automated test: panda-py's CI builds can't run a control loop without a robot. Both bugs were reproduced against the fake robot in the universal libfranka fork, which streams protocol 5 state (test/universal/fake_robot.py on JeanElsner/libfranka@universal), with panda-py built on that fork:

Reproduction 1.1.0 This branch
A reflex arrives while stop_controller() waits (--ignore-finish --reflex-after 1.5) hangs control thread logs the error; stop_controller() returns after 0.5 s
Longest gap between 1 kHz commands while get_log() reads 17,500 samples 23.8 ms 3.6 ms
  • Full suite passes on Python 3.13, built against libfranka 0.9.2 (98 tests).
  • CI across the libfranka matrix: all eight builds pass.
  • Hardware: on an FER (protocol 5), this branch built against stock libfranka 0.9.2, 2026-10-01. A 60 s hold with logging, then get_log() and stop_controller(), the sequence that hung on 1.1.0. get_log() read 60,004 samples in 216 ms with the control loop running throughout, and stop_controller() returned after 0.20 s.

Found in passing, not addressed here

When a robot stops streaming state mid-motion, libfranka's UDP receive times out and shuts down the TCP socket. Its cleanup then appears to hit the network again, and an uncaught Poco::Net::NetException escapes the control thread and terminates the process. That's upstream libfranka code, and it only arises when the robot goes silent altogether, as on network loss.

…_log

stop_controller() joined the control thread while holding the GIL. The
control thread takes the GIL to log, for instance when its loop ends with an
error, so if that happened during the stop neither thread could proceed.
Found on an FER: a 60 s hold ended with the motors released and the script
hanging, with the main thread in std::thread::join and the control thread in
take_gil. stopController() now releases the GIL around the join when it holds
it, which also covers the destructor.

What ended that loop was get_log(): it copied the whole log under the mutex
the control thread takes for every state, so reading a long log stalled the
1 kHz loop until the robot aborted. The log now has its own mutex, which
_setState only try-locks; a sample arriving during a read is not logged.

Reproduced against the fake robot in the universal libfranka fork, which
streams protocol 5 state: with a reflex arriving while stop_controller()
waits, 1.1.0 hangs and this returns in 0.5 s; reading 17,500 samples during
control left a 23.8 ms gap between commands in 1.1.0 and 3.6 ms with this.
@JeanElsner
JeanElsner merged commit 70dffda into main Oct 1, 2026
8 checks passed
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.

1 participant