Make MuJoCo Python bindings compatible with free-threading.

Introduce a new header `gil.h` defining `MutexLockIfGilDisabled` to support thread-safety in both standard and free-threaded CPython builds.

Protect critical shared states and registries:
- Guard global Python callback pointers in `callbacks.cc` using a mutex. Move `gil_scoped_acquire` into local blocks around refcount modifications to prevent `longjmp` from bypassing destructors.
- Protect raw pointer maps in `structs_wrappers.cc` with static mutexes.
- Replace TOCTOU race in `mjcb_time` initialization with thread-safe `std::call_once`.
- Add synchronization to lazy indexer array cache initialization in `indexers.cc` and `indexer_xmacro.h`.
- Protect vector mutations in `StructListBase::PopulateUpTo` in `structs.h` with a mutex.
- Revert unnecessary atomic changes to threadpool counters.
- Declare free-threading compatibility by passing `pybind11::mod_gil_not_used()` to all extension modules.

Fixes #3259
Fixes #3256
Fixes #2978

PiperOrigin-RevId: 941101502
Change-Id: Iec4ce58afcbc75d4b0be6a9a21fc8a47854242e3
This commit is contained in:
Saran Tunyasuvunakool
2026-07-01 08:08:10 -07:00
committed by Copybara-Service
parent cab191755a
commit a07ae6f849
21 changed files with 489 additions and 66 deletions
+156 -48
View File
@@ -22,6 +22,7 @@
#include <mujoco/mujoco.h>
#include "errors.h"
#include "gil.h"
#include "structs.h"
#include "raw.h"
#include <pybind11/eval.h>
@@ -32,6 +33,7 @@ namespace mujoco::python {
namespace {
namespace py = ::pybind11;
[[noreturn]] static void EscapeWithPythonException() {
mju_error("Python exception raised");
std::terminate(); // not actually reachable, mju_error doesn't return
@@ -121,6 +123,10 @@ static const py::handle MjWrapperLookup(const Raw* ptr) {
return MjWrapperLookup(const_cast<Raw*>(ptr));
}
// CallPyCallback takes ownership of py_callback: it will Py_XDECREF it before
// returning or escaping. This is necessary because EscapeWithPythonException()
// calls mju_error which uses longjmp, bypassing C++ stack unwinding and any
// trailing Py_XDECREF in callers.
template <typename Return, typename... Args>
static Return
CallPyCallback(const char* name, PyObject* py_callback, Args... args) {
@@ -135,9 +141,12 @@ CallPyCallback(const char* name, PyObject* py_callback, Args... args) {
try {
if constexpr (std::is_void_v<Return>) {
callback(args...);
Py_XDECREF(py_callback);
return;
} else {
return callback(args...).template cast<Return>();
auto result = callback(args...).template cast<Return>();
Py_XDECREF(py_callback);
return result;
}
} catch (py::error_already_set& e) {
e.restore();
@@ -154,13 +163,23 @@ CallPyCallback(const char* name, PyObject* py_callback, Args... args) {
PyErr_SetString(PyExc_TypeError, msg.str().c_str());
}
}
// Error path: DECREF before escaping (longjmp won't unwind the stack).
Py_XDECREF(py_callback);
}
EscapeWithPythonException();
}
static PyObject* py_mju_user_warning = nullptr;
static void PyMjuUserWarning(const char* msg) {
CallPyCallback<void>("mju_user_warning", py_mju_user_warning, msg);
PyObject* cb;
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(GetCallbackMutex());
cb = py_mju_user_warning;
Py_XINCREF(cb);
}
// CallPyCallback takes ownership of cb (will XDECREF it).
CallPyCallback<void>("mju_user_warning", cb, msg);
}
// We only support ctypes function pointers for these.
@@ -171,20 +190,41 @@ static PyObject* py_mju_user_free = nullptr;
static PyObject* py_mjcb_passive = nullptr;
static void PyMjcbPassive(const raw::MjModel* m, raw::MjData* d) {
CallPyCallback<void>("mjcb_passive", py_mjcb_passive,
PyObject* cb;
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(GetCallbackMutex());
cb = py_mjcb_passive;
Py_XINCREF(cb);
}
CallPyCallback<void>("mjcb_passive", cb,
MjWrapperLookup(m), MjWrapperLookup(d));
}
static PyObject* py_mjcb_control = nullptr;
static void PyMjcbControl(const raw::MjModel* m, raw::MjData* d) {
CallPyCallback<void>("mjcb_control", py_mjcb_control,
PyObject* cb;
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(GetCallbackMutex());
cb = py_mjcb_control;
Py_XINCREF(cb);
}
CallPyCallback<void>("mjcb_control", cb,
MjWrapperLookup(m), MjWrapperLookup(d));
}
static PyObject* py_mjcb_contactfilter = nullptr;
static int PyMjcbContactfilter(
const raw::MjModel* m, raw::MjData* d, int geom1, int geom2) {
return CallPyCallback<int>("mjcb_contactfilter", py_mjcb_contactfilter,
PyObject* cb;
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(GetCallbackMutex());
cb = py_mjcb_contactfilter;
Py_XINCREF(cb);
}
return CallPyCallback<int>("mjcb_contactfilter", cb,
MjWrapperLookup(m), MjWrapperLookup(d),
geom1, geom2);
}
@@ -192,34 +232,72 @@ static int PyMjcbContactfilter(
static PyObject* py_mjcb_sensor = nullptr;
static void
PyMjcbSensor(const raw::MjModel* m, raw::MjData* d, int stage) {
CallPyCallback<void>("mjcb_sensor", py_mjcb_sensor,
PyObject* cb;
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(GetCallbackMutex());
cb = py_mjcb_sensor;
Py_XINCREF(cb);
}
CallPyCallback<void>("mjcb_sensor", cb,
MjWrapperLookup(m), MjWrapperLookup(d), stage);
}
static PyObject* py_mjcb_time = nullptr;
static mjtNum PyMjcbTime() {
return CallPyCallback<mjtNum>("mjcb_time", py_mjcb_time);
PyObject* cb;
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(GetCallbackMutex());
cb = py_mjcb_time;
Py_XINCREF(cb);
}
return CallPyCallback<mjtNum>("mjcb_time", cb);
}
static PyObject* py_mjcb_act_dyn = nullptr;
static mjtNum
PyMjcbActDyn(const raw::MjModel* m, const raw::MjData* d, int id) {
return CallPyCallback<mjtNum>("mjcb_act_dyn", py_mjcb_act_dyn,
MjWrapperLookup(m), MjWrapperLookup(d), id);
PyObject* cb;
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(GetCallbackMutex());
cb = py_mjcb_act_dyn;
Py_XINCREF(cb);
}
return CallPyCallback<mjtNum>("mjcb_act_dyn", cb,
MjWrapperLookup(m),
MjWrapperLookup(d), id);
}
static PyObject* py_mjcb_act_gain = nullptr;
static mjtNum
PyMjcbActGain(const raw::MjModel* m, const raw::MjData* d, int id) {
return CallPyCallback<mjtNum>("mjcb_act_gain", py_mjcb_act_gain,
MjWrapperLookup(m), MjWrapperLookup(d), id);
PyObject* cb;
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(GetCallbackMutex());
cb = py_mjcb_act_gain;
Py_XINCREF(cb);
}
return CallPyCallback<mjtNum>("mjcb_act_gain", cb,
MjWrapperLookup(m),
MjWrapperLookup(d), id);
}
static PyObject* py_mjcb_act_bias = nullptr;
static mjtNum
PyMjcbActBias(const raw::MjModel* m, const raw::MjData* d, int id) {
return CallPyCallback<mjtNum>("mjcb_act_bias", py_mjcb_act_bias,
MjWrapperLookup(m), MjWrapperLookup(d), id);
PyObject* cb;
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(GetCallbackMutex());
cb = py_mjcb_act_bias;
Py_XINCREF(cb);
}
return CallPyCallback<mjtNum>("mjcb_act_bias", cb,
MjWrapperLookup(m),
MjWrapperLookup(d), id);
}
// If the Python object is a ctypes function pointer, returns the corresponding
@@ -287,66 +365,96 @@ template <typename CFuncPtr>
void SetCallback(py::handle h, CFuncPtr py_trampoline,
PyObject** py_callback, CFuncPtr* mj_callback) {
CFuncPtr cfuncptr = GetCFuncPtr<CFuncPtr>(h);
PyObject* old = nullptr;
if (h.is_none()) {
Py_XDECREF(*py_callback);
*py_callback = nullptr;
*mj_callback = nullptr;
{
MutexLockIfGilDisabled lock(GetCallbackMutex());
old = *py_callback;
*py_callback = nullptr;
*mj_callback = nullptr;
}
} else if (cfuncptr) {
Py_XDECREF(*py_callback);
Py_INCREF(h.ptr());
*py_callback = h.ptr();
*mj_callback = cfuncptr;
{
MutexLockIfGilDisabled lock(GetCallbackMutex());
old = *py_callback;
*py_callback = h.ptr();
*mj_callback = cfuncptr;
}
} else if (IsCallable(h)) {
Py_XDECREF(*py_callback);
Py_INCREF(h.ptr());
*py_callback = h.ptr();
*mj_callback = py_trampoline;
{
MutexLockIfGilDisabled lock(GetCallbackMutex());
old = *py_callback;
*py_callback = h.ptr();
*mj_callback = py_trampoline;
}
} else {
throw py::type_error("callback is not an Optional[Callable]");
}
// XDECREF outside mutex: __del__ may re-enter callback setters/getters.
Py_XDECREF(old);
}
py::object GetCallback(PyObject* py_callback) {
if (!py_callback) {
py::object GetCallback(PyObject** py_callback) {
MutexLockIfGilDisabled lock(GetCallbackMutex());
if (!*py_callback) {
return py::none();
}
return py::reinterpret_borrow<py::object>(py_callback);
return py::reinterpret_borrow<py::object>(*py_callback);
}
PYBIND11_MODULE(_callbacks, pymodule) {
PYBIND11_MODULE(_callbacks, pymodule, pybind11::mod_gil_not_used()) {
// Setters
pymodule.def("set_mju_user_warning", [](py::handle h) {
SetCallback(h, PyMjuUserWarning, &py_mju_user_warning, &::mju_user_warning);
SetCallback(h, PyMjuUserWarning, &py_mju_user_warning,
&::mju_user_warning);
});
pymodule.def("set_mju_user_malloc", [](py::handle h) {
PyObject* old = nullptr;
if (h.is_none()) {
Py_XDECREF(py_mju_user_malloc);
py_mju_user_malloc = nullptr;
{
MutexLockIfGilDisabled lock(GetCallbackMutex());
old = py_mju_user_malloc;
py_mju_user_malloc = nullptr;
}
} else {
auto* cfuncptr = GetCFuncPtr<decltype(::mju_user_malloc)>(h);
if (!cfuncptr) {
throw py::type_error("mju_user_malloc must be a C function pointer");
}
Py_XDECREF(py_mju_user_malloc);
Py_XINCREF(h.ptr());
py_mju_user_malloc = h.ptr();
::mju_user_malloc = cfuncptr;
{
MutexLockIfGilDisabled lock(GetCallbackMutex());
old = py_mju_user_malloc;
py_mju_user_malloc = h.ptr();
::mju_user_malloc = cfuncptr;
}
}
Py_XDECREF(old);
});
pymodule.def("set_mju_user_free", [](py::handle h) {
PyObject* old = nullptr;
if (h.is_none()) {
Py_XDECREF(py_mju_user_free);
py_mju_user_free = nullptr;
{
MutexLockIfGilDisabled lock(GetCallbackMutex());
old = py_mju_user_free;
py_mju_user_free = nullptr;
}
} else {
auto* cfuncptr = GetCFuncPtr<decltype(::mju_user_free)>(h);
if (!cfuncptr) {
throw py::type_error("mju_user_free must be a C function pointer");
}
Py_XDECREF(py_mju_user_free);
Py_XINCREF(h.ptr());
py_mju_user_free = h.ptr();
::mju_user_free = cfuncptr;
{
MutexLockIfGilDisabled lock(GetCallbackMutex());
old = py_mju_user_free;
py_mju_user_free = h.ptr();
::mju_user_free = cfuncptr;
}
}
Py_XDECREF(old);
});
pymodule.def("set_mjcb_passive", [](py::handle h) {
SetCallback(h, PyMjcbPassive, &py_mjcb_passive, &::mjcb_passive);
@@ -376,37 +484,37 @@ PYBIND11_MODULE(_callbacks, pymodule) {
// Getters
pymodule.def("get_mju_user_warning", []() {
return GetCallback(py_mju_user_warning);
return GetCallback(&py_mju_user_warning);
});
pymodule.def("get_mju_user_malloc", []() {
return GetCallback(py_mju_user_malloc);
return GetCallback(&py_mju_user_malloc);
});
pymodule.def("get_mju_user_free", []() {
return GetCallback(py_mju_user_free);
return GetCallback(&py_mju_user_free);
});
pymodule.def("get_mjcb_passive", []() {
return GetCallback(py_mjcb_passive);
return GetCallback(&py_mjcb_passive);
});
pymodule.def("get_mjcb_control", []() {
return GetCallback(py_mjcb_control);
return GetCallback(&py_mjcb_control);
});
pymodule.def("get_mjcb_contactfilter", []() {
return GetCallback(py_mjcb_contactfilter);
return GetCallback(&py_mjcb_contactfilter);
});
pymodule.def("get_mjcb_sensor", []() {
return GetCallback(py_mjcb_sensor);
return GetCallback(&py_mjcb_sensor);
});
pymodule.def("get_mjcb_time", []() {
return GetCallback(py_mjcb_time);
return GetCallback(&py_mjcb_time);
});
pymodule.def("get_mjcb_act_dyn", []() {
return GetCallback(py_mjcb_act_dyn);
return GetCallback(&py_mjcb_act_dyn);
});
pymodule.def("get_mjcb_act_gain", []() {
return GetCallback(py_mjcb_act_gain);
return GetCallback(&py_mjcb_act_gain);
});
pymodule.def("get_mjcb_act_bias", []() {
return GetCallback(py_mjcb_act_bias);
return GetCallback(&py_mjcb_act_bias);
});
} // PYBIND11_MODULE
} // namespace
+1 -1
View File
@@ -48,7 +48,7 @@ py::tuple MakeTuple(const char* (&strings)[N][3]) {
return std::move(result);
}
PYBIND11_MODULE(_constants, pymodule) {
PYBIND11_MODULE(_constants, pymodule, pybind11::mod_gil_not_used()) {
#define X(var) pymodule.attr(#var) = var
// from mjmodel.h
+1 -1
View File
@@ -144,7 +144,7 @@ void DefAllEnums(py::module_& m, Tuple&& tuple) {
}
}
PYBIND11_MODULE(_enums, pymodule) {
PYBIND11_MODULE(_enums, pymodule, pybind11::mod_gil_not_used()) {
DefAllEnums(pymodule, python_traits::kAllEnums);
}
} // namespace
+1 -1
View File
@@ -17,7 +17,7 @@
namespace mujoco::python {
namespace {
PYBIND11_MODULE(_errors, m) {
PYBIND11_MODULE(_errors, m, pybind11::mod_gil_not_used()) {
m.attr("FatalError") = FatalError::GetPyExc();
m.attr("UnexpectedError") = UnexpectedError::GetPyExc();
}
@@ -201,7 +201,7 @@ class Viewer {
std::vector<std::byte> pixels_;
};
PYBIND11_MODULE(native_viewer_cc, m) {
PYBIND11_MODULE(native_viewer_cc, m, pybind11::mod_gil_not_used()) {
pybind11::module_::import("mujoco._structs");
pybind11::class_<Viewer>(m, "Viewer")
.def(pybind11::init<const std::string&, int, int, const std::string&>())
+1 -1
View File
@@ -45,7 +45,7 @@ py::object Parse(std::string_view filepath) {
} // namespace mujoco::python
PYBIND11_MODULE(parser, m) {
PYBIND11_MODULE(parser, m, pybind11::mod_gil_not_used()) {
pybind11::module_::import("mujoco._structs");
m.def("parse", &mujoco::python::Parse,
pybind11::return_value_policy::take_ownership);
@@ -75,7 +75,7 @@ class Renderer {
} // namespace mujoco::python
PYBIND11_MODULE(renderer, m) {
PYBIND11_MODULE(renderer, m, pybind11::mod_gil_not_used()) {
pybind11::module_::import("mujoco._structs");
pybind11::class_<mujoco::python::Renderer>(m, "Renderer")
.def(pybind11::init<const std::string&>())
+1 -1
View File
@@ -25,7 +25,7 @@ namespace py = pybind11;
using StepControl = mujoco::platform::StepControl;
PYBIND11_MODULE(sim, m) {
PYBIND11_MODULE(sim, m, pybind11::mod_gil_not_used()) {
py::module_::import("mujoco._structs");
m.doc() = "MuJoCo platform simulation bindings for Link.";
+1 -1
View File
@@ -51,7 +51,7 @@ struct RenderFlags {
std::array<uint8_t, mjNRNDFLAG> flags = {0};
};
PYBIND11_MODULE(ux, m) {
PYBIND11_MODULE(ux, m, pybind11::mod_gil_not_used()) {
py::module_::import("mujoco._structs");
py::class_<RenderFlags>(m, "RenderFlags")
.def(py::init<>())
+1 -1
View File
@@ -37,7 +37,7 @@
namespace mujoco::python {
namespace {
PYBIND11_MODULE(_functions, pymodule) {
PYBIND11_MODULE(_functions, pymodule, pybind11::mod_gil_not_used()) {
namespace py = ::pybind11;
namespace traits = python_traits;
+62
View File
@@ -0,0 +1,62 @@
// Copyright 2026 DeepMind Technologies Limited
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#ifndef MUJOCO_PYTHON_GIL_H_
#define MUJOCO_PYTHON_GIL_H_
#include <mutex>
namespace mujoco::python {
// A scoped lock that acquires a std::mutex in free-threaded Python builds
// (Py_GIL_DISABLED) and does nothing in standard GIL builds.
//
// In standard builds the GIL already serializes access to Python state, so
// adding a C++ mutex would introduce unnecessary overhead and deadlock risk
// (two locks held simultaneously). In free-threaded builds the GIL is absent,
// so an explicit mutex is required to protect shared mutable C++ state.
//
// Usage:
// static std::mutex my_mutex;
// {
// MutexLockIfGilDisabled lock(my_mutex);
// // ... access shared state ...
// }
#ifdef Py_GIL_DISABLED
class MutexLockIfGilDisabled {
public:
explicit MutexLockIfGilDisabled(std::mutex& mtx) : lock_(mtx) {}
MutexLockIfGilDisabled(const MutexLockIfGilDisabled&) = delete;
MutexLockIfGilDisabled& operator=(const MutexLockIfGilDisabled&) = delete;
private:
std::lock_guard<std::mutex> lock_;
};
#else
class MutexLockIfGilDisabled {
public:
explicit MutexLockIfGilDisabled(std::mutex& /*unused*/) {}
MutexLockIfGilDisabled(const MutexLockIfGilDisabled&) = delete;
MutexLockIfGilDisabled& operator=(const MutexLockIfGilDisabled&) = delete;
};
#endif
inline std::mutex& GetCallbackMutex() {
static std::mutex mtx;
return mtx;
}
} // namespace mujoco::python
#endif // MUJOCO_PYTHON_GIL_H_
+7 -2
View File
@@ -19,6 +19,7 @@
#include <utility>
#include <vector>
#include "gil.h"
#include "indexers.h"
#include "raw.h"
#include "util/crossplatform.h"
@@ -184,7 +185,7 @@ MjModelIndexer::MjModelIndexer(raw::MjModel* m, py::handle owner)
name_to_id_(*m),
id_to_name_(*m)
#define XGROUP(MjModelFieldGroupedViews, field, nfield, FIELD_XMACROS) \
, field##_(m->nfield, std::nullopt)
, field##_(m->nfield)
MJMODEL_VIEW_GROUPS
#undef XGROUP
{}
@@ -194,6 +195,7 @@ MjModelIndexer::MjModelIndexer(raw::MjModel* m, py::handle owner)
if (i >= field##_.size() || i < 0) { \
throw py::index_error(IndexErrorMessage(i, field##_.size())); \
} \
MutexLockIfGilDisabled lock(lazy_init_mutex_); \
auto& indexer = field##_[i]; \
if (!indexer.has_value()) { \
const std::string& name = id_to_name_.field[i]; \
@@ -224,7 +226,7 @@ MjDataIndexer::MjDataIndexer(raw::MjData* d, const raw::MjModel* m,
name_to_id_(*m),
id_to_name_(*m)
#define XGROUP(MjDataGroupedViews, field, nfield, FIELD_XMACROS) \
, field##_(m->nfield, std::nullopt)
, field##_(m->nfield)
MJDATA_VIEW_GROUPS
#undef XGROUP
{}
@@ -234,6 +236,7 @@ MjDataIndexer::MjDataIndexer(raw::MjData* d, const raw::MjModel* m,
if (i >= field##_.size() || i < 0) { \
throw py::index_error(IndexErrorMessage(i, field##_.size())); \
} \
MutexLockIfGilDisabled lock(lazy_init_mutex_); \
auto& indexer = field##_[i]; \
if (!indexer.has_value()) { \
const std::string& name = id_to_name_.field[i]; \
@@ -270,6 +273,7 @@ MJDATA_VIEW_GROUPS
#define MJ_M(n) m_->n
#define X(type, prefix, var, dim0, dim1) \
py::array_t<type> XGROUP::var() { \
MutexLockIfGilDisabled lock(lazy_init_mutex_); \
if (!var##_.has_value()) { \
var##_.emplace(MakeArray<&raw::MjModel::dim0>( \
m_->prefix##var, index_, MAKE_SHAPE(dim1), *m_, owner_)); \
@@ -361,6 +365,7 @@ MJMODEL_KEYFRAME
#define MJ_M(n) m_->n
#define X(type, prefix, var, dim0, dim1) \
py::array_t<type> XGROUP::var() { \
MutexLockIfGilDisabled lock(lazy_init_mutex_); \
if (!var##_.has_value()) { \
var##_.emplace(MakeArray<&raw::MjModel::dim0>( \
d_->prefix##var, index_, MAKE_SHAPE(dim1), *m_, owner_)); \
+5
View File
@@ -15,6 +15,7 @@
#ifndef MUJOCO_PYTHON_INDEXERS_H_
#define MUJOCO_PYTHON_INDEXERS_H_
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
@@ -104,6 +105,7 @@ class MjModelGroupedViewsBase {
std::string name_;
raw::MjModel* m_;
pybind11::handle owner_;
mutable std::mutex lazy_init_mutex_;
};
#define XGROUP(MjModelGroupedViews, field, nfield, FIELD_XMACROS) \
@@ -142,6 +144,7 @@ class MjModelIndexer {
pybind11::handle owner_;
NameToIDMappings name_to_id_;
IDToNameMappings id_to_name_;
mutable std::mutex lazy_init_mutex_;
// Lazily instantiate a grouped views object when accessed from Python, but
// cache it once made so that we can return the same one if requested again.
@@ -172,6 +175,7 @@ class MjDataGroupedViewsBase {
raw::MjData* d_;
const raw::MjModel* m_;
pybind11::handle owner_;
mutable std::mutex lazy_init_mutex_;
};
#define XGROUP(MjDataGroupedViews, field, nfield, FIELD_XMACROS) \
@@ -212,6 +216,7 @@ class MjDataIndexer {
pybind11::handle owner_;
NameToIDMappings name_to_id_;
IDToNameMappings id_to_name_;
mutable std::mutex lazy_init_mutex_;
// Lazily instantiate a grouped views object when accessed from Python, but
// cache it once made so that we can return the same one if requested again.
+1 -1
View File
@@ -147,7 +147,7 @@ void MjrContextWrapper::Free() {
} // namespace _impl
namespace {
PYBIND11_MODULE(_render, pymodule) {
PYBIND11_MODULE(_render, pymodule, pybind11::mod_gil_not_used()) {
namespace py = ::pybind11;
namespace traits = python_traits;
+1 -1
View File
@@ -330,7 +330,7 @@ class Rollout {
std::shared_ptr<ThreadPool> pool_;
};
PYBIND11_MODULE(_rollout, pymodule) {
PYBIND11_MODULE(_rollout, pymodule, pybind11::mod_gil_not_used()) {
namespace py = ::pybind11;
py::class_<Rollout>(pymodule, "Rollout")
+1 -1
View File
@@ -323,7 +323,7 @@ inline auto SetIfNotNull(T mujoco::Simulate::* member) {
};
}
PYBIND11_MODULE(_simulate, pymodule) {
PYBIND11_MODULE(_simulate, pymodule, pybind11::mod_gil_not_used()) {
py::class_<SimulateMutex>(pymodule, "Mutex")
.def(
"__enter__", [](SimulateMutex& mtx) { mtx.lock(); },
+1 -1
View File
@@ -225,7 +225,7 @@ static std::string addPrefixAndSuffix(const std::string& original_path,
return addSuffixBeforeExtension(prefixed_path, suffix_to_add);
}
PYBIND11_MODULE(_specs, m) {
PYBIND11_MODULE(_specs, m, pybind11::mod_gil_not_used()) {
auto structs_m = py::module::import("mujoco._structs");
py::function mjmodel_from_raw_ptr =
structs_m.attr("MjModel").attr("_from_model_ptr");
+1 -1
View File
@@ -101,7 +101,7 @@ py::tuple RecompileSpec(raw::MjSpec* spec, const MjModelWrapper& old_m,
} // namespace
PYBIND11_MODULE(_structs, m) {
PYBIND11_MODULE(_structs, m, pybind11::mod_gil_not_used()) {
py::module_::import("mujoco._enums");
// ==================== MJOPTION =============================================
+11 -1
View File
@@ -16,6 +16,7 @@
#define MUJOCO_PYTHON_STRUCTS_H_
#include <algorithm>
#include <mutex>
#include <array>
#include <cctype>
#include <cstddef>
@@ -35,6 +36,7 @@
#include <absl/types/span.h>
#include <mujoco/mujoco.h>
#include <mujoco/mjxmacro.h>
#include "gil.h"
#include "indexers.h"
#include "raw.h"
#include <pybind11/numpy.h>
@@ -154,7 +156,12 @@ class StructListBase {
}
StructListBase(const StructListBase& other) = delete;
StructListBase(StructListBase&& other) = default;
StructListBase(StructListBase&& other)
: ptr_(other.ptr_),
num_(other.num_),
owner_(std::move(other.owner_)),
wrappers_(std::move(other.wrappers_)) {}
// populate_mutex_ is default-constructed (std::mutex is not movable)
virtual ~StructListBase() = default;
@@ -175,6 +182,8 @@ class StructListBase {
protected:
void PopulateUpTo(int n) {
MutexLockIfGilDisabled lock(populate_mutex_);
wrappers_.reserve(n + 1);
while (wrappers_.size() <= n) {
wrappers_.push_back(
std::make_shared<MjWrapper<T>>(&ptr_[wrappers_.size()], owner_));
@@ -202,6 +211,7 @@ class StructListBase {
// Using shared_ptr here so that we get identical Python objects when slicing.
std::vector<std::shared_ptr<MjWrapper<T>>> wrappers_;
mutable std::mutex populate_mutex_;
};
template <typename T>
+26 -1
View File
@@ -23,6 +23,7 @@
#include <ios>
#include <iostream>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
@@ -35,6 +36,7 @@
#include <mujoco/mjxmacro.h>
#include <mujoco/mujoco.h>
#include "errors.h"
#include "gil.h"
#include "private.h"
#include "raw.h"
#include "serialization.h"
@@ -50,6 +52,9 @@
namespace mujoco::python::_impl {
using ::mujoco::python::GetCallbackMutex;
using ::mujoco::python::MutexLockIfGilDisabled;
namespace py = ::pybind11;
namespace {
@@ -221,11 +226,17 @@ MjModelRawPointerMap() {
return *hash_map;
}
static std::mutex& MjModelMapMutex() {
static auto* mtx = new std::mutex;
return *mtx;
}
MjModelWrapper* MjModelWrapper::FromRawPointer(raw::MjModel* m) noexcept {
try {
auto& map = MjModelRawPointerMap();
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(MjModelMapMutex());
auto found = map.find(m);
return found != map.end() ? found->second : nullptr;
}
@@ -250,6 +261,7 @@ MjModelWrapper::MjWrapper(raw::MjModel* ptr)
bool is_newly_inserted = false;
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(MjModelMapMutex());
is_newly_inserted = MjModelRawPointerMap().insert({ptr_, this}).second;
}
if (!is_newly_inserted) {
@@ -271,6 +283,7 @@ MjModelWrapper::MjWrapper(MjModelWrapper&& other)
bool is_newly_inserted = false;
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(MjModelMapMutex());
is_newly_inserted =
MjModelRawPointerMap().insert_or_assign(ptr_, this).second;
}
@@ -294,6 +307,7 @@ MjModelWrapper::~MjWrapper() {
bool erased = false;
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(MjModelMapMutex());
erased = MjModelRawPointerMap().erase(ptr_);
}
if (!erased) {
@@ -611,11 +625,17 @@ absl::flat_hash_map<raw::MjData*, MjDataWrapper*>& MjDataRawPointerMap() {
return *hash_map;
}
static std::mutex& MjDataMapMutex() {
static auto* mtx = new std::mutex;
return *mtx;
}
MjDataWrapper* MjDataWrapper::FromRawPointer(raw::MjData* m) noexcept {
try {
auto& map = MjDataRawPointerMap();
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(MjDataMapMutex());
auto found = map.find(m);
return found != map.end() ? found->second : nullptr;
}
@@ -657,6 +677,7 @@ MjDataWrapper::MjWrapper(MjModelWrapper* model)
bool is_newly_inserted = false;
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(MjDataMapMutex());
is_newly_inserted = MjDataRawPointerMap().insert({ptr_, this}).second;
}
if (!is_newly_inserted) {
@@ -666,7 +687,7 @@ MjDataWrapper::MjWrapper(MjModelWrapper* model)
// install default timer if not already installed
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(GetCallbackMutex());
if (!mjcb_time) {
mjcb_time = GetTime;
}
@@ -727,6 +748,7 @@ MjDataWrapper::MjWrapper(MjDataWrapper&& other)
bool is_newly_inserted = false;
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(MjDataMapMutex());
is_newly_inserted =
MjDataRawPointerMap().insert_or_assign(ptr_, this).second;
}
@@ -760,6 +782,7 @@ MjDataWrapper::MjWrapper(const MjDataWrapper& other, MjModelWrapper* model)
bool is_newly_inserted = false;
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(MjDataMapMutex());
is_newly_inserted = MjDataRawPointerMap().insert({ptr_, this}).second;
}
if (!is_newly_inserted) {
@@ -790,6 +813,7 @@ MjDataWrapper::MjWrapper(MjModelWrapper* model, raw::MjData* d)
bool is_newly_inserted = false;
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(MjDataMapMutex());
is_newly_inserted = MjDataRawPointerMap().insert({ptr_, this}).second;
}
if (!is_newly_inserted) {
@@ -803,6 +827,7 @@ MjDataWrapper::~MjWrapper() {
bool erased = false;
{
py::gil_scoped_acquire gil;
MutexLockIfGilDisabled lock(MjDataMapMutex());
erased = MjDataRawPointerMap().erase(ptr_);
}
if (!erased) {
+208
View File
@@ -0,0 +1,208 @@
# Copyright 2026 DeepMind Technologies Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Thread-safety tests for MuJoCo Python bindings.
Validates that the free-threading mutex guards in the bindings work correctly:
- Concurrent access to lazy-initialized indexers and struct lists.
- Concurrent callback setter/getter operations.
- Concurrent MjModel/MjData creation and destruction.
- Re-entrancy safety (no deadlock when __del__ re-enters callback setters).
"""
import threading
from absl.testing import absltest
import mujoco
# A model with contacts so data.contact is non-empty after stepping.
_CONTACT_XML = r"""
<mujoco>
<worldbody>
<geom type="plane" size="1 1 0.1"/>
<body pos="0 0 0.05">
<freejoint/>
<geom type="sphere" size="0.05"/>
</body>
</worldbody>
</mujoco>
"""
_NUM_THREADS = 8
_ITERS_PER_THREAD = 50
class ConcurrentStressTest(absltest.TestCase):
"""Tests concurrent access to bindings under multiple threads."""
def test_concurrent_indexer_access(self):
"""Concurrent first-access of lazy indexers must not corrupt state."""
model = mujoco.MjModel.from_xml_string(_CONTACT_XML)
data = mujoco.MjData(model)
mujoco.mj_step(model, data)
errors = []
def access_indexers():
try:
for _ in range(_ITERS_PER_THREAD):
# Each of these triggers lazy init on first access.
_ = model.geom(0)
_ = data.qpos
_ = data.xpos
except Exception as e: # pylint: disable=broad-except
errors.append(e)
threads = [threading.Thread(target=access_indexers)
for _ in range(_NUM_THREADS)]
for t in threads:
t.start()
for t in threads:
t.join()
self.assertEmpty(errors, f"Errors in concurrent indexer access: {errors}")
def test_concurrent_struct_list_access(self):
"""Concurrent access to contact struct lists must not crash."""
model = mujoco.MjModel.from_xml_string(_CONTACT_XML)
data = mujoco.MjData(model)
# Step to generate contacts.
mujoco.mj_step(model, data)
errors = []
def access_contacts():
try:
for _ in range(_ITERS_PER_THREAD):
ncon = data.ncon
if ncon > 0:
contacts = data.contact[:ncon]
_ = len(contacts)
except Exception as e: # pylint: disable=broad-except
errors.append(e)
threads = [threading.Thread(target=access_contacts)
for _ in range(_NUM_THREADS)]
for t in threads:
t.start()
for t in threads:
t.join()
self.assertEmpty(
errors, f"Errors in concurrent struct list access: {errors}")
def test_concurrent_callback_set_get(self):
"""Concurrent callback setter/getter calls must not crash or deadlock."""
errors = []
def toggle_callback():
try:
for i in range(_ITERS_PER_THREAD):
if i % 2 == 0:
mujoco.set_mjcb_passive(lambda m, d: None)
else:
mujoco.set_mjcb_passive(None)
_ = mujoco.get_mjcb_passive()
except Exception as e: # pylint: disable=broad-except
errors.append(e)
threads = [threading.Thread(target=toggle_callback)
for _ in range(_NUM_THREADS)]
for t in threads:
t.start()
for t in threads:
t.join()
# Clean up.
mujoco.set_mjcb_passive(None)
self.assertEmpty(
errors, f"Errors in concurrent callback set/get: {errors}")
def test_concurrent_model_data_lifecycle(self):
"""Concurrent MjModel/MjData creation and destruction must not crash."""
errors = []
def create_destroy():
try:
for _ in range(_ITERS_PER_THREAD):
m = mujoco.MjModel.from_xml_string(_CONTACT_XML)
d = mujoco.MjData(m)
mujoco.mj_step(m, d)
del d
del m
except Exception as e: # pylint: disable=broad-except
errors.append(e)
threads = [threading.Thread(target=create_destroy)
for _ in range(_NUM_THREADS)]
for t in threads:
t.start()
for t in threads:
t.join()
self.assertEmpty(
errors, f"Errors in concurrent model/data lifecycle: {errors}")
class ReentrancyTest(absltest.TestCase):
"""Tests that mutexes don't deadlock when __del__ re-enters setters."""
def test_callback_setter_from_del(self):
"""Setting a callback whose __del__ re-enters the setter must not hang."""
class ReentrantCallback:
"""A callable whose destructor re-enters the callback setter."""
def __call__(self, m, d):
pass
def __del__(self):
# When this object is destroyed by set_mjcb_passive(None) or by
# being replaced, __del__ will try to set the callback again.
# This must not deadlock.
try:
mujoco.set_mjcb_passive(None)
except Exception: # pylint: disable=broad-except
pass # Swallow — we just want to verify no deadlock.
mujoco.set_mjcb_passive(ReentrantCallback())
# This replaces the callback → drops last ref → triggers __del__ →
# re-enters set_mjcb_passive. Must complete without hanging.
mujoco.set_mjcb_passive(None)
# If we get here, no deadlock occurred.
self.assertIsNone(mujoco.get_mjcb_passive())
def test_callback_replacement_from_del(self):
"""Replacing a callback whose __del__ sets a new callback must not hang."""
class ChainedCallback:
"""A callable whose destructor sets a different callback."""
def __call__(self, m, d):
pass
def __del__(self):
try:
mujoco.set_mjcb_passive(lambda m, d: None)
except Exception: # pylint: disable=broad-except
pass
mujoco.set_mjcb_passive(ChainedCallback())
# Replace with a plain lambda — old ChainedCallback.__del__ fires.
mujoco.set_mjcb_passive(lambda m, d: None)
# Clean up.
cb = mujoco.get_mjcb_passive()
self.assertIsNotNone(cb)
mujoco.set_mjcb_passive(None)
if __name__ == "__main__":
absltest.main()