Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
e19a62fd1This file is pinned to the selected release. Download the original to open it in your design tool.
# 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()