Skip to content

Commit e38573c

Browse files
yuvaltassacopybara-github
authored andcommitted
Reclaim deleted mjSpec elements automatically via reference counting
Fixes #2882. Previously, `mjCModel::operator-=(mjsElement*)` pushed deleted elements onto `detached_` without calling `Release()`, deferring reclamation until `~mjCModel()` (`mj_deleteSpec`) so Python handles to deleted elements would not become dangling pointers when passed to `model.bind` / `data.bind`. In workflows that repeatedly added and deleted elements or subtrees on a long-lived `mjSpec`, memory grew unbounded. - Remove `mjCModel::detached_` and call `Release()` immediately when removing elements in `mjCModel::operator-=(mjsElement*)`. - Set `id = -1` before `Release()` in `RemoveFromList`, `RemovePlugins`, and `operator-=(const mjCDef&)` so implicitly removed elements also report invalid IDs. - Add intrusive reference counting (`AddRef` / `Release`) to `mjCDef` and use `Release()` on `mjCWrap` in `~mjCTendon()`. - Add `MjsElementRef<T>` custom pybind11 holder for `Mjs*` wrapper types in `specs.cc` using internal `_mjPRIVATE_addRefElement` / `_mjPRIVATE_releaseElement` entry points so C++ elements stay alive while referenced by Python variables and are freed automatically upon Python GC. PiperOrigin-RevId: 985105555 Change-Id: I8ffc0064ff56920c96135feb73e75cf6073d12e6
1 parent 1f519a1 commit e38573c

12 files changed

Lines changed: 349 additions & 200 deletions

File tree

‎doc/changelog.rst‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ Bug fixes
5757
register the forces that tendons apply to bodies (:issue:`832`).
5858
- :ref:`mjd_transitionFD` and :ref:`mjd_inverseFD` now raise an error when :ref:`sleeping<Sleeping>` is enabled.
5959
Previously, their repeated evaluations changed the sleep state, leading to internal errors or wrong derivatives.
60+
- Fixed a memory leak in :ref:`mjs_delete` where deleted elements and subtrees were retained in ``mjSpec`` until
61+
:ref:`mj_deleteSpec` (:issue:`2882`).
6062
- Fixed an out-of-bounds read when parsing the header of a :ref:`GMSH file<gmsh-file-docs>` loaded by
6163
:ref:`flexcomp<body-flexcomp>`. Truncated headers are now reported as an error.
6264

‎python/mujoco/private.h‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
#include <mujoco/mjdata.h>
1919
#include <mujoco/mjexport.h>
20+
#include <mujoco/mjspec.h>
2021
#include <mujoco/mjtype.h>
2122

2223
// DO NOT USE THESE FUNCTIONS ELSEWHERE.
@@ -25,6 +26,8 @@ extern "C" {
2526
MJAPI mjfLogHandler _mjPRIVATE_setTlsLogHandler(mjfLogHandler handler);
2627
MJAPI mjfLogHandler _mjPRIVATE_getGlobalLogHandler(void);
2728
MJAPI void* mj_arenaAllocByte(mjData* d, int bytes, int alignment);
29+
MJAPI void _mjPRIVATE_addRefElement(mjsElement* element);
30+
MJAPI void _mjPRIVATE_releaseElement(mjsElement* element);
2831
}
2932

3033
#endif // MUJOCO_PYTHON_PRIVATE_H_

‎python/mujoco/specs.cc‎

Lines changed: 140 additions & 172 deletions
Large diffs are not rendered by default.

‎python/mujoco/specs_test.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2380,5 +2380,47 @@ def test_bind_rejects_stale_model_after_first_equality(self):
23802380
model = spec.compile()
23812381
self.assertEqual(model.bind(equality).id, 0)
23822382

2383+
def test_delete_reclaims_memory_and_invalidates_bind(self):
2384+
spec = mujoco.MjSpec()
2385+
for i in range(100):
2386+
body = spec.worldbody.add_body(name=f'body_{i}')
2387+
joint = body.add_joint(
2388+
name=f'joint_{i}', type=mujoco.mjtJoint.mjJNT_HINGE
2389+
)
2390+
body.add_geom(size=[0.1, 0.1, 0.1])
2391+
act = spec.add_actuator(
2392+
name=f'act_{i}',
2393+
target=f'joint_{i}',
2394+
trntype=mujoco.mjtTrn.mjTRN_JOINT,
2395+
)
2396+
spec.delete(body)
2397+
2398+
model = spec.compile()
2399+
data = mujoco.MjData(model)
2400+
self.assertEqual(model.nbody, 1)
2401+
self.assertEqual(model.nu, 0)
2402+
2403+
# A Python reference to a deleted body or implicitly deleted actuator
2404+
# remains memory-safe for attribute access and raises IndexError on bind.
2405+
self.assertEqual(body.name, 'body_99')
2406+
self.assertIsNone(body.parent)
2407+
self.assertEqual(joint.name, 'joint_99')
2408+
self.assertEqual(act.name, 'act_99')
2409+
with self.assertRaisesRegex(IndexError, 'Invalid index -1'):
2410+
data.bind(body)
2411+
with self.assertRaisesRegex(IndexError, 'Invalid index -1'):
2412+
data.bind(joint)
2413+
with self.assertRaisesRegex(IndexError, 'Invalid index -1'):
2414+
data.bind(act)
2415+
2416+
# A child obtained via spec.joints (whose reference_internal parent is spec,
2417+
# not the body) stays alive with parent cleared to None once its body is
2418+
# deleted and freed.
2419+
spec.worldbody.add_body(name='temp_body').add_joint(name='orphan_joint')
2420+
orphan = spec.joints[-1]
2421+
spec.delete(spec.bodies[-1])
2422+
self.assertEqual(orphan.name, 'orphan_joint')
2423+
self.assertIsNone(orphan.parent)
2424+
23832425
if __name__ == '__main__':
23842426
absltest.main()

‎python/mujoco/specs_wrapper.h‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
#include "private.h"
1516
#include "raw.h"
1617
#include <pybind11/cast.h>
1718
#include <pybind11/pybind11.h>
@@ -22,6 +23,69 @@ namespace py = ::pybind11;
2223

2324
namespace mujoco::python {
2425

26+
template <typename T>
27+
class MjsElementRef {
28+
public:
29+
MjsElementRef() : ptr_(nullptr), element_(nullptr) {}
30+
explicit MjsElementRef(T* ptr) : ptr_(ptr), element_(GetElement(ptr)) {
31+
if (element_) {
32+
_mjPRIVATE_addRefElement(element_);
33+
}
34+
}
35+
MjsElementRef(const MjsElementRef& other)
36+
: ptr_(other.ptr_), element_(other.element_) {
37+
if (element_) {
38+
_mjPRIVATE_addRefElement(element_);
39+
}
40+
}
41+
MjsElementRef(MjsElementRef&& other) noexcept
42+
: ptr_(other.ptr_), element_(other.element_) {
43+
other.ptr_ = nullptr;
44+
other.element_ = nullptr;
45+
}
46+
MjsElementRef& operator=(const MjsElementRef& other) {
47+
if (this != &other) {
48+
if (other.element_) {
49+
_mjPRIVATE_addRefElement(other.element_);
50+
}
51+
if (element_) {
52+
_mjPRIVATE_releaseElement(element_);
53+
}
54+
ptr_ = other.ptr_;
55+
element_ = other.element_;
56+
}
57+
return *this;
58+
}
59+
MjsElementRef& operator=(MjsElementRef&& other) noexcept {
60+
if (this != &other) {
61+
if (element_) {
62+
_mjPRIVATE_releaseElement(element_);
63+
}
64+
ptr_ = other.ptr_;
65+
element_ = other.element_;
66+
other.ptr_ = nullptr;
67+
other.element_ = nullptr;
68+
}
69+
return *this;
70+
}
71+
~MjsElementRef() {
72+
if (element_) {
73+
_mjPRIVATE_releaseElement(element_);
74+
}
75+
}
76+
T* get() const { return ptr_; }
77+
78+
private:
79+
static raw::MjsElement* GetElement(raw::MjsElement* ptr) { return ptr; }
80+
template <typename U>
81+
static raw::MjsElement* GetElement(U* ptr) {
82+
return ptr ? ptr->element : nullptr;
83+
}
84+
85+
T* ptr_;
86+
raw::MjsElement* element_;
87+
};
88+
2589
struct MjSpec {
2690
MjSpec();
2791
MjSpec(raw::MjSpec* ptr, const py::dict& assets_ = {});
@@ -43,3 +107,5 @@ struct MjSpec {
43107
MjSpec* parent = nullptr;
44108
};
45109
} // namespace mujoco::python
110+
111+
PYBIND11_DECLARE_HOLDER_TYPE(T, mujoco::python::MjsElementRef<T>, true);

‎src/user/user_api.cc‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1738,19 +1738,15 @@ mjsBody* mjs_findChild(const mjsBody* bodyspec, const char* name) {
17381738
mjsBody* mjs_getParent(const mjsElement* element) {
17391739
switch (element->elemtype) {
17401740
case mjOBJ_BODY:
1741-
return &(static_cast<const mjCBody*>(element)->GetParent()->spec);
17421741
case mjOBJ_FRAME:
1743-
return &(static_cast<const mjCFrame*>(element)->GetParent()->spec);
17441742
case mjOBJ_JOINT:
1745-
return &(static_cast<const mjCJoint*>(element)->GetParent()->spec);
17461743
case mjOBJ_GEOM:
1747-
return &(static_cast<const mjCGeom*>(element)->GetParent()->spec);
17481744
case mjOBJ_SITE:
1749-
return &(static_cast<const mjCSite*>(element)->GetParent()->spec);
17501745
case mjOBJ_CAMERA:
1751-
return &(static_cast<const mjCCamera*>(element)->GetParent()->spec);
1752-
case mjOBJ_LIGHT:
1753-
return &(static_cast<const mjCLight*>(element)->GetParent()->spec);
1746+
case mjOBJ_LIGHT: {
1747+
mjCBody* parent = static_cast<mjCBody*>(static_cast<const mjCBase*>(element)->GetParent());
1748+
return parent ? &parent->spec : nullptr;
1749+
}
17541750
default:
17551751
return nullptr;
17561752
}
@@ -2621,3 +2617,29 @@ void mjs_setAuthored(const void* elem_ptr, const void* field_ptr, int authored)
26212617
#undef SET_FIELD
26222618
#undef SET_FIELD_VEC
26232619
}
2620+
2621+
2622+
// increment reference count of element (for internal bindings use only)
2623+
void _mjPRIVATE_addRefElement(mjsElement* element) {
2624+
if (!element) { return; }
2625+
if (element->elemtype == mjOBJ_DEFAULT) {
2626+
static_cast<mjCDef*>(element)->AddRef();
2627+
return;
2628+
}
2629+
// embedded sub-elements inside mjCDef are by-value members with model == nullptr
2630+
mjCBase* base = static_cast<mjCBase*>(element);
2631+
if (base->model) { base->AddRef(); }
2632+
}
2633+
2634+
2635+
// decrement reference count of element (for internal bindings use only)
2636+
void _mjPRIVATE_releaseElement(mjsElement* element) {
2637+
if (!element) { return; }
2638+
if (element->elemtype == mjOBJ_DEFAULT) {
2639+
static_cast<mjCDef*>(element)->Release();
2640+
return;
2641+
}
2642+
// embedded sub-elements inside mjCDef are by-value members with model == nullptr
2643+
mjCBase* base = static_cast<mjCBase*>(element);
2644+
if (base->model) { base->Release(); }
2645+
}

‎src/user/user_api.h‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,10 @@ MJAPI void mj_clearCache(mjCache* cache);
620620
// Get the internal asset cache used by the compiler.
621621
MJAPI mjCache* mj_getCache(void);
622622

623+
// Internal element reference counting for language bindings.
624+
MJAPI void _mjPRIVATE_addRefElement(mjsElement* element);
625+
MJAPI void _mjPRIVATE_releaseElement(mjsElement* element);
626+
623627
#ifdef __cplusplus
624628
} // extern "C"
625629
#endif

‎src/user/user_model.cc‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,7 @@ void mjCModel::RemoveFromList(std::vector<T*>& list, const mjCModel& other) {
508508
element->ResolveReferences(this);
509509
} catch (mjCError err) {
510510
ids[element->elemtype].erase(element->name);
511+
element->id = -1;
511512
element->Release();
512513
list.erase(list.begin() + i);
513514
nlist--;
@@ -554,6 +555,7 @@ void mjCModel::RemovePlugins() {
554555
if (plugins_[i]->name.empty()) { continue; }
555556
if (instances.find(plugins_[i]->name) == instances.end()) {
556557
ids[plugins_[i]->elemtype].erase(plugins_[i]->name);
558+
plugins_[i]->id = -1;
557559
plugins_[i]->Release();
558560
plugins_.erase(plugins_.begin() + i);
559561
nlist--;
@@ -659,7 +661,8 @@ mjCModel& mjCModel::operator-=(const mjCDef& subtree) {
659661
std::sort(default_ids_to_remove.begin(), default_ids_to_remove.end(), std::greater<int>());
660662

661663
for (int id : default_ids_to_remove) {
662-
delete defaults_[id];
664+
defaults_[id]->id = -1;
665+
defaults_[id]->Release();
663666
defaults_.erase(defaults_.begin() + id);
664667
}
665668

@@ -693,14 +696,6 @@ void mjCModel::DeleteSubtreePlugin(mjCBody* subtree) {
693696

694697
// remove the element from the model
695698
void mjCModel::operator-=(mjsElement* el) {
696-
if (el->elemtype == mjOBJ_BODY) {
697-
mjCBody* body = static_cast<mjCBody*>(el);
698-
*this -= *body;
699-
}
700-
701-
detached_.push_back(static_cast<mjCBase*>(el));
702-
ResetTreeLists();
703-
704699
if (el->elemtype != mjOBJ_DEFAULT) {
705700
if (static_cast<mjCBase*>(el)->model != this) {
706701
throw mjCError(nullptr, "element is not in this model");
@@ -711,6 +706,13 @@ void mjCModel::operator-=(mjsElement* el) {
711706
}
712707
}
713708

709+
if (el->elemtype == mjOBJ_BODY) {
710+
mjCBody* body = static_cast<mjCBody*>(el);
711+
*this -= *body;
712+
}
713+
714+
ResetTreeLists();
715+
714716
switch (el->elemtype) {
715717
case mjOBJ_BODY: {
716718
MakeTreeLists(); // rebuild lists that were reset at the beginning of the function
@@ -783,6 +785,8 @@ void mjCModel::operator-=(mjsElement* el) {
783785

784786
// update signature after we updated everything
785787
spec.element->signature = Signature();
788+
789+
static_cast<mjCBase*>(el)->Release();
786790
}
787791

788792

@@ -1071,10 +1075,9 @@ mjCModel::~mjCModel() {
10711075
for (int i = 0; i < texts_.size(); i++) texts_[i]->Release();
10721076
for (int i = 0; i < tuples_.size(); i++) tuples_[i]->Release();
10731077
for (int i = 0; i < keys_.size(); i++) keys_[i]->Release();
1074-
for (int i = 0; i < defaults_.size(); i++) delete defaults_[i];
1078+
for (int i = 0; i < defaults_.size(); i++) defaults_[i]->Release();
10751079
for (int i = 0; i < specs_.size(); i++) mj_deleteSpec(specs_[i]);
10761080
for (int i = 0; i < plugins_.size(); i++) plugins_[i]->Release();
1077-
for (int i = 0; i < detached_.size(); i++) detached_[i]->Release();
10781081

10791082
// clear sizes and pointer lists created in Compile
10801083
Clear();

‎src/user/user_model.h‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,5 @@ class mjCModel : public mjCModel_, private mjSpec {
535535
bool deepcopy_; // copy objects when attaching
536536
bool attached_ = false; // true if model is attached to a parent model
537537
std::unordered_map<const mjsCompiler*, mjSpec*> compiler2spec_; // map from compiler to spec
538-
std::vector<mjCBase*> detached_; // list of detached objects
539538
};
540539
#endif // MUJOCO_SRC_USER_USER_MODEL_H_

‎src/user/user_objects.cc‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1793,6 +1793,8 @@ void mjCBody::CopyList(std::vector<T*>& dst,
17931793
mjCBody& mjCBody::operator-=(const mjCBody& subtree) {
17941794
for (int i = 0; i < bodies.size(); i++) {
17951795
if (bodies[i] == &subtree) {
1796+
bodies[i]->SetParent(nullptr);
1797+
bodies[i]->frame = nullptr;
17961798
bodies.erase(bodies.begin() + i);
17971799
break;
17981800
}
@@ -1889,13 +1891,20 @@ void mjCBody::CopyPlugin() {
18891891

18901892
// destructor
18911893
mjCBody::~mjCBody() {
1892-
for (int i = 0; i < bodies.size(); i++) bodies[i]->Release();
1893-
for (int i = 0; i < geoms.size(); i++) geoms[i]->Release();
1894-
for (int i = 0; i < frames.size(); i++) frames[i]->Release();
1895-
for (int i = 0; i < joints.size(); i++) joints[i]->Release();
1896-
for (int i = 0; i < sites.size(); i++) sites[i]->Release();
1897-
for (int i = 0; i < cameras.size(); i++) cameras[i]->Release();
1898-
for (int i = 0; i < lights.size(); i++) lights[i]->Release();
1894+
auto release_children = [](auto& list) {
1895+
for (auto* child : list) {
1896+
child->SetParent(nullptr);
1897+
child->frame = nullptr;
1898+
child->Release();
1899+
}
1900+
};
1901+
release_children(bodies);
1902+
release_children(geoms);
1903+
release_children(frames);
1904+
release_children(joints);
1905+
release_children(sites);
1906+
release_children(cameras);
1907+
release_children(lights);
18991908
}
19001909

19011910

@@ -6175,7 +6184,7 @@ void mjCTendon::CopyFromSpec() {
61756184
// desctructor
61766185
mjCTendon::~mjCTendon() {
61776186
// delete objects allocated here
6178-
for (unsigned int i = 0; i < path.size(); i++) { delete path[i]; }
6187+
for (unsigned int i = 0; i < path.size(); i++) { path[i]->Release(); }
61796188

61806189
path.clear();
61816190
}

0 commit comments

Comments
 (0)
⌂ 换网址