Refactor compiler warning handling.

Compiler warnings are now accumulated in a vector of strings within the mjSpec object. New API functions `mjs_numWarnings` and `mjs_getWarning` are added to access these warnings. The compiler's log handler now chains warnings to the global log handler, ensuring they are still displayed immediately. Call sites in `mj_loadXML`, `mj_compile`, and the Python and WASM bindings have been updated to use the new warning API.

PiperOrigin-RevId: 933361650
Change-Id: I47cab98a460c57b0898c0a1a43fce2a5b9648eb1
This commit is contained in:
Yuval Tassa
2026-06-16 16:27:52 -07:00
committed by Copybara-Service
parent 55c6332f20
commit 6f8bb5ef55
25 changed files with 640 additions and 89 deletions
+19 -1
View File
@@ -2161,7 +2161,25 @@ Get compiler timing diagnostics from spec, returns pointer to array of size mjNC
.. mujoco-include:: mjs_isWarning
Return 1 if compiler error is a warning.
Return 1 if compiler error is a warning. Deprecated: use mjs_numWarnings(s) > 0.
.. _mjs_numWarnings:
`mjs_numWarnings <#mjs_numWarnings>`__
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. mujoco-include:: mjs_numWarnings
Get number of warnings accumulated in the spec.
.. _mjs_getWarning:
`mjs_getWarning <#mjs_getWarning>`__
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. mujoco-include:: mjs_getWarning
Get the i-th warning message (returns nullptr if index out of bounds).
.. _Miscellaneous:
+2
View File
@@ -20,6 +20,8 @@ General
- New types: :ref:`mjtLogLevel`, :ref:`mjtLogTopic`, :ref:`mjLogMessage`, :ref:`mjLogConfig`.
- The legacy callbacks :ref:`mju_user_error` and :ref:`mju_user_warning` are deprecated but remain functional.
- Added :ref:`mjs_numWarnings` and :ref:`mjs_getWarning` for retrieving all warnings accumulated during model
compilation and attachment. Deprecated :ref:`mjs_isWarning` in favor of ``mjs_numWarnings(s) > 0``.
- Improved primal solver convergence under float32. Improvements initially proposed by :github:user:`n3b` in
:issue:`2313` and :github:user:`denzeler-nvidia` in :doc:`MJWarp <mjwarp/index>` pull request
`1374 <https://github.com/google-deepmind/mujoco_warp/pull/1374>`__.
+2
View File
@@ -3555,6 +3555,8 @@ void mju_writeLog(const char* type, const char* msg);
const char* mjs_getError(mjSpec* s);
const double* mjs_getTimer(mjSpec* s);
int mjs_isWarning(mjSpec* s);
int mjs_numWarnings(const mjSpec* spec);
const char* mjs_getWarning(const mjSpec* spec, int index);
void mju_zero3(mjtNum res[3]);
void mju_copy3(mjtNum res[3], const mjtNum data[3]);
void mju_scl3(mjtNum res[3], const mjtNum vec[3], mjtNum scl);
+6 -1
View File
@@ -1015,9 +1015,14 @@ MJAPI const char* mjs_getError(mjSpec* s);
// Get compiler timing diagnostics from spec, returns pointer to array of size mjNCTIMER.
MJAPI const double* mjs_getTimer(mjSpec* s);
// Return 1 if compiler error is a warning.
// Return 1 if compiler error is a warning. Deprecated: use mjs_numWarnings(s) > 0.
MJAPI int mjs_isWarning(mjSpec* s);
// Get number of warnings accumulated in the spec.
MJAPI int mjs_numWarnings(const mjSpec* spec);
// Get the i-th warning message (returns nullptr if index out of bounds).
MJAPI const char* mjs_getWarning(const mjSpec* spec, int index);
//---------------------------------- Standard math -------------------------------------------------
+35 -1
View File
@@ -6486,7 +6486,41 @@ FUNCTIONS: Mapping[str, FunctionDecl] = dict([
),
),
),
doc='Return 1 if compiler error is a warning.',
doc='Return 1 if compiler error is a warning. Deprecated: use mjs_numWarnings(s) > 0.', # pylint: disable=line-too-long
)),
('mjs_numWarnings',
FunctionDecl(
name='mjs_numWarnings',
return_type=ValueType(name='int'),
parameters=(
FunctionParameterDecl(
name='spec',
type=PointerType(
inner_type=ValueType(name='mjSpec', is_const=True),
),
),
),
doc='Get number of warnings accumulated in the spec.',
)),
('mjs_getWarning',
FunctionDecl(
name='mjs_getWarning',
return_type=PointerType(
inner_type=ValueType(name='char', is_const=True),
),
parameters=(
FunctionParameterDecl(
name='spec',
type=PointerType(
inner_type=ValueType(name='mjSpec', is_const=True),
),
),
FunctionParameterDecl(
name='index',
type=ValueType(name='int'),
),
),
doc='Get the i-th warning message (returns nullptr if index out of bounds).', # pylint: disable=line-too-long
)),
('mju_zero3',
FunctionDecl(
+12
View File
@@ -597,6 +597,18 @@ class SpecsTest(absltest.TestCase):
with self.assertRaisesRegex(ValueError, expected_error):
spec.compile()
def test_compile_warnings(self):
xml = """
<mujoco>
<worldbody>
<flexcomp name="my_flex" type="grid" count="3 3 1" spacing=".05 .05 .05" radius=".01" dim="2"/>
</worldbody>
</mujoco>
"""
spec = mujoco.MjSpec.from_string(xml)
with self.assertWarnsRegex(UserWarning, 'is not rigid'):
spec.compile()
def test_recompile(self):
# Create a spec.
spec = mujoco.MjSpec()
+17 -1
View File
@@ -24,6 +24,7 @@
#include <mujoco/mujoco.h>
#include "errors.h"
#include "indexers.h" // IWYU pragma: keep
#include "private.h"
#include "raw.h"
#include "structs.h" // IWYU pragma: keep
#include <pybind11/cast.h>
@@ -118,7 +119,14 @@ raw::MjModel* MjSpec::Compile(mjVFS* vfs) {
raw::MjModel* m;
{
py::gil_scoped_release no_gil;
// Install a no-op handler to suppress stderr output from warnings.
// Compile() installs its own (setjmp/longjmp) handler and then chains to
// prev. We want to raise a `warnings.warn`, so pass in a no-op handler.
mjfLogHandler prev =
_mjPRIVATE_setTlsLogHandler([](const mjLogMessage*) {});
m = mj_compile(ptr, vfs);
_mjPRIVATE_setTlsLogHandler(prev);
}
if (local_vfs.has_value()) {
@@ -127,9 +135,17 @@ raw::MjModel* MjSpec::Compile(mjVFS* vfs) {
local_vfs = std::nullopt;
}
if (!m || mjs_isWarning(ptr)) {
if (!m) {
throw py::value_error(mjs_getError(ptr));
}
int num_warnings = mjs_numWarnings(ptr);
if (num_warnings > 0) {
py::object warnings = py::module_::import("warnings");
for (int i = 0; i < num_warnings; ++i) {
warnings.attr("warn")(mjs_getWarning(ptr, i));
}
}
return m;
}
+26 -4
View File
@@ -483,15 +483,37 @@ const double* mjs_getTimer(mjSpec* s) {
return modelC->timer;
}
// check if model has warnings
// check if model has warnings (but no error)
// TODO(tassa): delete this function
int mjs_isWarning(mjSpec* s) {
if (!s) {
return 0;
}
mjCModel* modelC = static_cast<mjCModel*>(s->element);
return modelC->GetError().warning;
return modelC->GetError().message[0] == '\0' &&
!modelC->GetWarnings().empty();
}
// get number of warnings
int mjs_numWarnings(const mjSpec* spec) {
if (!spec) {
return 0;
}
const mjCModel* modelC = static_cast<const mjCModel*>(spec->element);
return static_cast<int>(modelC->GetWarnings().size());
}
// get the i-th warning message
const char* mjs_getWarning(const mjSpec* spec, int index) {
if (!spec) {
return nullptr;
}
const mjCModel* modelC = static_cast<const mjCModel*>(spec->element);
if (index < 0 || index >= static_cast<int>(modelC->GetWarnings().size())) {
return nullptr;
}
return modelC->GetWarnings()[index].c_str();
}
// delete model
void mj_deleteSpec(mjSpec* s) {
+2 -2
View File
@@ -1546,7 +1546,7 @@ void mjCMesh::Process() {
for (int i = 0; i < nface(); i++) {
SetBoundingVolume(i, dvert.data());
}
tree_.CreateBVH();
tree_.CreateBVH(model, this);
}
mesh_timer_[mjCTIMER_MESH_BVH] += Seconds(Clock::now() - t0).count();
@@ -5451,7 +5451,7 @@ void mjCFlex::CreateBVH() {
// create hierarchy
tree.RemoveInactiveVolumes(nbvh);
tree.CreateBVH();
tree.CreateBVH(model, this);
}
+66 -18
View File
@@ -1239,6 +1239,7 @@ void mjCModel::Clear() {
hasImplicitPluginElem = false;
compiled = false;
errInfo = mjCError();
ClearCompileWarnings();
qpos0.clear();
}
@@ -1508,7 +1509,21 @@ const mjCError& mjCModel::GetError() const {
return errInfo;
}
// add warning to vector (immediate delivery outside compile)
void mjCModel::AddWarning(std::string msg, const mjCBase* obj) {
if (obj) {
msg += "\nElement name '" + obj->name + "', id " + std::to_string(obj->id);
if (!obj->info.empty()) {
msg += ", " + obj->info;
}
}
// outside compile: deliver immediately via normal handler chain
if (!compiling_) {
mju_warning("%s", msg.c_str());
}
warnings_.push_back(std::move(msg));
}
// pointer to world body
mjCBody* mjCModel::GetWorld() {
@@ -3551,8 +3566,10 @@ void mjCModel::CopyObjects(mjModel* m) {
if (!pfl->rigid && m->flex_edgeequality[i] == 0 &&
!pfl->edgestiffness && !pfl->edgedamping && !pfl->damping &&
pfl->bending.empty()) {
mju_warning("flex '%s' is not rigid and has no equality constraints "
"or passive forces", pfl->name.c_str());
AddWarning("flex '" + pfl->name +
"' is not rigid and has no equality constraints or "
"passive forces",
pfl);
}
// copy bvh data (flex aabb computed dynamically in mjData)
@@ -4212,9 +4229,10 @@ template void mjCModel::RestoreState<mjtNum>(
// resolve keyframe references
void mjCModel::StoreKeyframes(mjCModel* dest) {
if (this != dest && !key_pending_.empty()) {
mju_warning(
"Child model has pending keyframes. They will not be namespaced correctly. "
"To prevent this, compile the child model before attaching it again.");
dest->AddWarning(
"Child model has pending keyframes. They will not be namespaced "
"correctly. "
"To prevent this, compile the child model before attaching it again.");
}
// do not change compilation quantities in case the user wants to recompile preserving the state
@@ -4633,10 +4651,17 @@ static void compilerLogHandler(const mjLogMessage* msg) {
mju::strcpy_arr(errortext, msg->subject);
std::longjmp(error_jmp_buf, 1);
} else if (msg->level == mjLOG_WARNING) {
// buffer for structured capture (append, not overwrite)
if (local_warningtext_ptr) {
*local_warningtext_ptr = msg->subject;
if (!local_warningtext_ptr->empty()) {
*local_warningtext_ptr += '\n';
}
*local_warningtext_ptr += msg->subject;
} else {
mju::strcpy_arr(warningtext, msg->subject);
if (warningtext[0]) {
mju::strcat_arr(warningtext, "\n");
}
mju::strcat_arr(warningtext, msg->subject);
}
}
}
@@ -4661,10 +4686,15 @@ mjModel* mjCModel::Compile(const mjVFS* vfs, mjModel** m) {
mjModel* volatile model = (m && *m) ? *m : nullptr;
mjData* volatile data = nullptr;
// save log handler
mjfLogHandler save_handler = _mjPRIVATE_setTlsLogHandler(compilerLogHandler);
// install compiler log handler (captures warnings silently)
mjfLogHandler prev_tls = _mjPRIVATE_setTlsLogHandler(compilerLogHandler);
errInfo = mjCError();
// set flag so warnings are captured in the spec vector rather than delivered
// immediately
compiling_ = true;
ClearCompileWarnings();
warningtext[0] = 0;
try {
@@ -4677,7 +4707,7 @@ mjModel* mjCModel::Compile(const mjVFS* vfs, mjModel** m) {
// also include the last warning that was issued. this is useful for
// warnings that came out of plugin implementations.
if (warningtext[0]) {
error_msg += "\n";
error_msg += '\n';
error_msg += warningtext;
}
throw mjCError(0, "engine error: %s", error_msg.c_str());
@@ -4701,13 +4731,21 @@ mjModel* mjCModel::Compile(const mjVFS* vfs, mjModel** m) {
}
// restore handler, return 0
_mjPRIVATE_setTlsLogHandler(save_handler);
_mjPRIVATE_setTlsLogHandler(prev_tls);
compiling_ = false;
return nullptr;
}
// restore log handler, mark as compiled, return mjModel
_mjPRIVATE_setTlsLogHandler(save_handler);
// restore log handler
_mjPRIVATE_setTlsLogHandler(prev_tls);
compiling_ = false;
compiled = true;
// play back compile warnings through the normal handler chain
for (int i = num_attach_warnings_; i < warnings_.size(); ++i) {
mju_warning("%s", warnings_[i].c_str());
}
return model;
}
@@ -5353,7 +5391,22 @@ void mjCModel::TryCompile(mjModel*& m, mjData*& d, const mjVFS* vfs) {
throw mjCError(0, "could not create mjData");
}
// pass compiler warnings into structured warning vector before validation
if (warningtext[0]) {
std::string warnings(warningtext);
std::istringstream stream(warnings);
std::string line;
while (std::getline(stream, line)) {
if (!line.empty()) {
AddWarning(line);
}
}
}
// test forward simulation unless asleep_init is true (potentially expensive)
// reset warningtext: engine warnings from validation are not compiler
// warnings
warningtext[0] = 0;
if (!asleep_init) {
mj_step(m, d);
}
@@ -5364,11 +5417,6 @@ void mjCModel::TryCompile(mjModel*& m, mjData*& d, const mjVFS* vfs) {
m->opt.enableflags = enableflags;
d = nullptr;
// pass warning back
if (warningtext[0]) {
mju::strcpy_arr(errInfo.message, warningtext);
errInfo.warning = true;
}
// save signature
m->signature = Signature();
+25 -3
View File
@@ -249,6 +249,23 @@ class mjCModel : public mjCModel_, private mjSpec {
bool IsCompiled() const; // is model already compiled
const mjCError& GetError() const; // get reference of error object
void SetError(const mjCError& error) { errInfo = error; } // set value of error object
void AddWarning(std::string msg, // add warning to vector
const mjCBase* obj = nullptr);
const std::vector<std::string>& GetWarnings()
const { // get accumulated warnings
return warnings_;
}
void ClearWarnings() {
warnings_.clear();
num_attach_warnings_ = 0;
} // clear all warnings
void ClearCompileWarnings() {
warnings_.resize(num_attach_warnings_);
} // clear compile warnings
void SetAttachWarningBoundary() { // snapshot attach warning count
num_attach_warnings_ = warnings_.size();
}
mjCBody* GetWorld(); // pointer to world body
mjCDef* FindDefault(const std::string& name) const; // find defaults class name
mjCDef* AddDefault(std::string name, mjCDef* parent = nullptr); // add defaults class to array
@@ -488,10 +505,15 @@ class mjCModel : public mjCModel_, private mjSpec {
// expand all keyframes in the model
void ExpandAllKeyframes();
mjListKeyMap ids; // map from object names to ids
mjCError errInfo; // last error info
mjListKeyMap ids; // map from object names to ids
mjCError errInfo; // last error info
std::vector<std::string>
warnings_; // chronological list of non-fatal warnings
int num_attach_warnings_ =
0; // boundary: [0, n) are attach, [n, size) are compile
bool compiling_ = false; // true during Compile()
std::vector<mjKeyInfo> key_pending_; // attached keyframes
bool deepcopy_; // copy objects when attaching
bool deepcopy_; // copy objects when attaching
bool attached_ = false; // true if model is attached to a parent model
std::unordered_map<const mjsCompiler*, mjSpec*> compiler2spec_; // map from compiler to spec
std::vector<mjCBase*> detached_; // list of detached objects
+16 -17
View File
@@ -38,12 +38,11 @@
#include <utility>
#include <vector>
#include "lodepng.h"
#include "cc/array_safety.h"
#include "engine/engine_passive.h"
#include "engine/engine_support.h"
#include "lodepng.h" // NOLINT
#include <mujoco/mjspec.h>
#include <mujoco/mujoco.h>
#include "cc/array_safety.h"
#include "engine/engine_passive.h"
#include "user/user_api.h"
#include "user/user_cache.h"
#include "user/user_model.h"
@@ -197,7 +196,6 @@ mjCError::mjCError(const mjCBase* obj, const char* msg, const char* str, int pos
char temp[600];
// init
warning = false;
if (obj || msg) {
mju::sprintf_arr(message, "Error");
} else {
@@ -396,13 +394,13 @@ mjCBoundingVolumeHierarchy::AddBoundingVolume(const int* id, int contype, int co
// create bounding volume hierarchy
void mjCBoundingVolumeHierarchy::CreateBVH() {
void mjCBoundingVolumeHierarchy::CreateBVH(mjCModel* model,
const mjCBase* owner) {
std::vector<BVElement> elements;
Make(elements);
MakeBVH(elements.begin(), elements.end());
MakeBVH(elements.begin(), elements.end(), 0, model, owner);
}
void mjCBoundingVolumeHierarchy::Make(std::vector<BVElement>& elements) {
// precompute the positions of each element in the hierarchy's axes, and drop
// visual-only elements.
@@ -424,8 +422,9 @@ void mjCBoundingVolumeHierarchy::Make(std::vector<BVElement>& elements) {
// compute bounding volume hierarchy
int mjCBoundingVolumeHierarchy::MakeBVH(
std::vector<BVElement>::iterator elements_begin,
std::vector<BVElement>::iterator elements_end, int lev) {
std::vector<BVElement>::iterator elements_begin,
std::vector<BVElement>::iterator elements_end, int lev, mjCModel* model,
const mjCBase* owner) {
int nelements = elements_end - elements_begin;
if (nelements == 0) {
return -1;
@@ -525,11 +524,13 @@ int mjCBoundingVolumeHierarchy::MakeBVH(
// recursive calls
if (m > 0) {
child_[2*index + 0] = MakeBVH(elements_begin, elements_begin + m, lev + 1);
child_[2 * index + 0] =
MakeBVH(elements_begin, elements_begin + m, lev + 1, model, owner);
}
if (m != nelements) {
child_[2*index + 1] = MakeBVH(elements_begin + m, elements_end, lev + 1);
child_[2 * index + 1] =
MakeBVH(elements_begin + m, elements_end, lev + 1, model, owner);
}
// SHOULD NOT OCCUR
@@ -539,14 +540,12 @@ int mjCBoundingVolumeHierarchy::MakeBVH(
}
if (lev > mjMAXTREEDEPTH) {
mju_warning("max tree depth exceeded in body=%s", name_.c_str());
model->AddWarning("max tree depth exceeded", owner);
}
return index;
}
//------------------------- class mjCOctree implementation --------------------------------------------
void mjCOctree::CopyLevel(int* level) const {
@@ -2631,7 +2630,7 @@ void mjCBody::ComputeBVH() {
tree.AddBoundingVolume(&geom->id, geom->contype, geom->conaffinity,
geom->pos, geom->quat, geom->aabb);
}
tree.CreateBVH();
tree.CreateBVH(model, this);
}
@@ -3871,7 +3870,7 @@ void mjCGeom::SetFluidCoefs(void) {
// compute bounding box
void mjCGeom::ComputeAABB(void) {
double aamm[6]; // axis-aligned bounding box in (min, max) format
double aamm[6]; // axis-aligned bounding box in (min, max) format
switch (type) {
case mjGEOM_HFIELD:
aamm[0] = -hfield->size[0];
+3 -3
View File
@@ -83,7 +83,6 @@ class [[nodiscard]] mjCError {
int pos2 = 0);
char message[500]; // error message
bool warning; // is this a warning instead of error
};
// alternative specifications of frame orientation
@@ -172,7 +171,7 @@ struct mjCBoundingVolumeHierarchy_ {
class mjCBoundingVolumeHierarchy : public mjCBoundingVolumeHierarchy_ {
public:
// make bounding volume hierarchy
void CreateBVH();
void CreateBVH(mjCModel* model, const mjCBase* owner);
void Set(double ipos_element[3], double iquat_element[4]);
void AllocateBoundingVolumes(int nleaf);
void RemoveInactiveVolumes(int nmax);
@@ -210,7 +209,8 @@ class mjCBoundingVolumeHierarchy : public mjCBoundingVolumeHierarchy_ {
};
void Make(std::vector<BVElement>& elements);
int MakeBVH(std::vector<BVElement>::iterator elements_begin,
std::vector<BVElement>::iterator elements_end, int lev = 0);
std::vector<BVElement>::iterator elements_end, int lev,
mjCModel* model, const mjCBase* owner);
};
+10 -2
View File
@@ -58,8 +58,16 @@ mjModel* mj_loadXML(const char* filename, const mjVFS* vfs,
}
// handle compile warning
if (mjs_isWarning(spec.get())) {
mjCopyError(error, mjs_getError(spec.get()), error_sz);
int num_warnings = mjs_numWarnings(spec.get());
if (num_warnings > 0) {
std::string all_warnings;
for (int i = 0; i < num_warnings; ++i) {
if (!all_warnings.empty()) {
all_warnings += '\n';
}
all_warnings += mjs_getWarning(spec.get(), i);
}
mjCopyError(error, all_warnings.c_str(), error_sz);
} else if (error) {
error[0] = '\0';
}
+3 -1
View File
@@ -80,7 +80,9 @@ struct ActLimitedTestCase {
mjtIntegrator integrator;
};
using ParametrizedForwardTest = ::testing::TestWithParam<ActLimitedTestCase>;
class ParametrizedForwardTest
: public MujocoTest,
public ::testing::WithParamInterface<ActLimitedTestCase> {};
TEST_P(ParametrizedForwardTest, ActLimited) {
static constexpr char xml[] = R"(
+4
View File
@@ -1753,6 +1753,8 @@ TEST_F(SensorTest, InsideSiteFlexBody) {
)";
char error[1024] = {0};
EXPECT_CALL(mock_warning_handler, Warn(testing::HasSubstr("is not rigid")))
.WillOnce(testing::Return());
mjModel* m = LoadModelFromString(xml, error, sizeof(error));
ASSERT_THAT(m, NotNull()) << error;
mjData* d = mj_makeData(m);
@@ -1839,6 +1841,8 @@ TEST_F(SensorTest, FlexContactSensors) {
)";
char error[1024] = {0};
EXPECT_CALL(mock_warning_handler, Warn(testing::HasSubstr("is not rigid")))
.WillOnce(testing::Return());
mjModel* m = LoadModelFromString(xml, error, sizeof(error));
ASSERT_THAT(m, NotNull()) << error;
mjData* d = mj_makeData(m);
+4 -2
View File
@@ -45,7 +45,9 @@ constexpr int GetExpectedStackUsageBytes() {
}
}
TEST(TestMjArrayList, TestMjArrayListSingleThreaded) {
class TestMjArrayList : public MujocoTest {};
TEST_F(TestMjArrayList, TestMjArrayListSingleThreaded) {
std::array<char, 1024> error;
mjModel* m = LoadModelFromString("<mujoco/>", error.data(), error.size());
ASSERT_THAT(m, NotNull()) << "Failed to load model: " << error.data();
@@ -81,7 +83,7 @@ TEST(TestMjArrayList, TestMjArrayListSingleThreaded) {
mj_deleteModel(m);
}
TEST(TestMjArrayList, ZeroInitialCapacity) {
TEST_F(TestMjArrayList, ZeroInitialCapacity) {
char error[1024];
mjModel* m = LoadModelFromString("<mujoco/>", error, sizeof(error));
ASSERT_THAT(m, NotNull()) << "Failed to load model: " << error;
+99 -13
View File
@@ -24,7 +24,6 @@
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
@@ -33,6 +32,7 @@
#include <absl/base/attributes.h>
#include <absl/base/const_init.h>
#include <absl/base/thread_annotations.h>
#include <absl/strings/match.h>
#include <absl/strings/str_cat.h>
#include <absl/strings/str_join.h>
#include <absl/synchronization/mutex.h>
@@ -41,35 +41,103 @@
#include "src/xml/xml_global.h"
namespace mujoco {
namespace {
using ::testing::_;
using ::testing::Not;
using ::testing::Return;
using ::testing::Truly;
// Returns true if the warning matches a known benign warning to ignore.
bool IsBenignWarning(const std::string& msg) {
static const char* const kBenignWarnings[] = {
"is not rigid and has no equality constraints",
};
for (const char* warning : kBenignWarnings) {
if (absl::StrContains(msg, warning)) {
return true;
}
}
return false;
}
} // namespace
thread_local MockWarningHandler* MockWarningHandler::active_handler = nullptr;
// Registers this handler as the active one.
MockWarningHandler::MockWarningHandler() {
prev_ = active_handler;
active_handler = this;
// By default, ignore matches in the benign warnings list
ON_CALL(*this, Warn(Truly(IsBenignWarning)))
.WillByDefault([](const std::string&) {});
// Fail on all other warnings
ON_CALL(*this, Warn(Not(Truly(IsBenignWarning))))
.WillByDefault([](const std::string& msg) {
ADD_FAILURE() << "mju_user_warning: " << msg;
});
}
// Restores the previously active warning handler.
MockWarningHandler::~MockWarningHandler() { active_handler = prev_; }
// Configures the mock warning handler to ignore all warnings.
void MockWarningHandler::ExpectWarnings() {
EXPECT_CALL(*this, Warn(_)).WillRepeatedly(Return());
}
// Returns the active warning handler.
MockWarningHandler* MockWarningHandler::GetActive() { return active_handler; }
namespace {
using ::testing::NotNull;
ABSL_CONST_INIT static absl::Mutex handlers_mutex(absl::kConstInit);
static int guard_count ABSL_GUARDED_BY(handlers_mutex) = 0;
static mjfLogHandler prev_log_handler ABSL_GUARDED_BY(handlers_mutex) = nullptr;
void default_mj_error_handler(const char* msg) {
FAIL() << "mju_user_error: " << msg;
}
void default_mj_log_handler(const mjLogMessage* msg) {
std::string subject = msg->subject;
if (msg->func) {
subject = std::string(msg->func) + ": " + msg->subject;
}
void default_mj_warning_handler(const char* msg) {
ADD_FAILURE() << "mju_user_warning: " << msg;
if (msg->level == mjLOG_ERROR) {
if (mju_user_error) {
mju_user_error(subject.c_str());
} else {
FAIL() << "mju_user_error: " << subject;
}
} else if (msg->level == mjLOG_WARNING) {
std::string full_msg = subject;
if (msg->body) {
full_msg += "\n" + std::string(msg->body);
}
if (mju_user_warning) {
mju_user_warning(full_msg.c_str());
} else if (auto* handler = MockWarningHandler::GetActive()) {
handler->Warn(full_msg);
} else {
ADD_FAILURE() << "mju_user_warning: " << full_msg;
}
}
}
} // namespace
MujocoErrorTestGuard::MujocoErrorTestGuard() {
absl::MutexLock lock(handlers_mutex);
if (++guard_count == 1) {
mju_user_error = default_mj_error_handler;
mju_user_warning = default_mj_warning_handler;
prev_log_handler = mju_setLogHandler(default_mj_log_handler);
}
}
MujocoErrorTestGuard::~MujocoErrorTestGuard() {
absl::MutexLock lock(handlers_mutex);
if (--guard_count == 0) {
mju_user_error = nullptr;
mju_user_warning = nullptr;
mju_setLogHandler(prev_log_handler);
prev_log_handler = nullptr;
}
}
@@ -97,11 +165,29 @@ mjModel* LoadModelFromString(std::string_view xml, char* error,
if (spec) {
model = mj_compile(spec, vfs);
if (error && (!model || mjs_isWarning(spec))) {
strncpy(error, mjs_getError(spec), error_size);
error[error_size - 1] = '\0';
if (error) {
if (!model) {
strncpy(error, mjs_getError(spec), error_size);
error[error_size - 1] = '\0';
} else {
int num_warnings = mjs_numWarnings(spec);
if (num_warnings > 0) {
std::string all_warnings;
for (int i = 0; i < num_warnings; ++i) {
if (!all_warnings.empty()) {
all_warnings += '\n';
}
all_warnings += mjs_getWarning(spec, i);
}
strncpy(error, all_warnings.c_str(), error_size);
error[error_size - 1] = '\0';
} else {
error[0] = '\0';
}
}
}
}
SetGlobalXmlSpec(spec);
return model;
}
+26 -1
View File
@@ -44,7 +44,7 @@ namespace mujoco {
inline mjtNum MjTolScale() {
static const mjtNum scale = []() {
const char* env = std::getenv("MJTOL_SCALE");
return env ? std::atof(env) : 1.0;
return env ? std::strtod(env, nullptr) : 1.0;
}();
return scale;
}
@@ -106,6 +106,28 @@ class MujocoErrorTestGuard {
~MujocoErrorTestGuard();
};
// Mock handler for capturing and verifying mju_warning logs.
class MockWarningHandler {
public:
// Constructor that registers this handler as the active one.
MockWarningHandler();
// Destructor that restores the previously active handler.
~MockWarningHandler();
// Mock method called when a warning is intercepted.
MOCK_METHOD(void, Warn, (const std::string& msg));
// Allow any number of warnings without triggering test failure.
void ExpectWarnings();
// Returns the thread-local active mock warning handler.
static MockWarningHandler* GetActive();
private:
static thread_local MockWarningHandler* active_handler;
MockWarningHandler* prev_ = nullptr;
};
// A test fixture which simplifies writing tests for the MuJoCo C API.
// By default, any MuJoCo operation which triggers a warning or error will
// trigger a test failure.
@@ -128,6 +150,9 @@ class MujocoTest : public ::testing::Test {
}
~MujocoTest() { mj_freeLastXML(); }
protected:
MockWarningHandler mock_warning_handler;
private:
MujocoErrorTestGuard error_guard;
};
+7
View File
@@ -34,6 +34,13 @@ TEST_F(MujocoTestTest, MjUserWarningFailsTest) {
EXPECT_NONFATAL_FAILURE(mju_warning("Warning."), "Warning.");
}
TEST_F(MujocoTestTest, BenignWarningDoesNotFailTest) {
// Warnings in the benign list should not trigger test failures
mju_warning(
"flex 'soft' is not rigid and has no equality constraints "
"or passive forces");
}
TEST_F(MujocoTestTest, MjUserErrorFailsTest) {
EXPECT_FATAL_FAILURE(mju_error("Error."), "Error.");
}
+163
View File
@@ -3291,5 +3291,168 @@ TEST_F(MujocoTest, CompilerTimers) {
mj_deleteSpec(spec);
}
// -------------------- test compile warning infrastructure --------------------
TEST_F(MujocoTest, CompileWarningCount) {
static constexpr char xml[] = R"(
<mujoco>
<worldbody>
<body name="parent">
<geom size="1"/>
<flexcomp name="grid" type="grid" count="3 3 1" spacing="0.1 0.1 0.1"
dim="2" radius="0.01">
<contact internal="false"/>
</flexcomp>
</body>
</worldbody>
</mujoco>
)";
std::array<char, 1024> error;
mjSpec* spec = mj_parseXMLString(xml, 0, error.data(), error.size());
ASSERT_THAT(spec, NotNull()) << error.data();
mjModel* model = mj_compile(spec, 0);
ASSERT_THAT(model, NotNull());
// flex with no passive forces should produce a warning
EXPECT_GT(mjs_numWarnings(spec), 0);
EXPECT_THAT(mjs_getWarning(spec, 0), HasSubstr("not rigid"));
mj_deleteModel(model);
mj_deleteSpec(spec);
}
TEST_F(MujocoTest, CompileWarningOutOfBounds) {
mjSpec* spec = mj_makeSpec();
mjsBody* world = mjs_findBody(spec, "world");
mjsGeom* geom = mjs_addGeom(world, 0);
geom->size[0] = 1;
mjModel* model = mj_compile(spec, 0);
ASSERT_THAT(model, NotNull());
// no warnings expected for simple model
EXPECT_EQ(mjs_numWarnings(spec), 0);
EXPECT_THAT(mjs_getWarning(spec, 0), IsNull());
EXPECT_THAT(mjs_getWarning(spec, -1), IsNull());
// nullptr spec should not crash
EXPECT_EQ(mjs_numWarnings(nullptr), 0);
EXPECT_THAT(mjs_getWarning(nullptr, 0), IsNull());
mj_deleteModel(model);
mj_deleteSpec(spec);
}
TEST_F(MujocoTest, RecompileClearsCompileWarnings) {
static constexpr char xml[] = R"(
<mujoco>
<worldbody>
<body name="parent">
<geom size="1"/>
<flexcomp name="grid" type="grid" count="3 3 1" spacing="0.1 0.1 0.1"
dim="2" radius="0.01">
<contact internal="false"/>
</flexcomp>
</body>
</worldbody>
</mujoco>
)";
std::array<char, 1024> error;
mjSpec* spec = mj_parseXMLString(xml, 0, error.data(), error.size());
ASSERT_THAT(spec, NotNull()) << error.data();
mjModel* model = mj_compile(spec, 0);
ASSERT_THAT(model, NotNull());
int first_count = mjs_numWarnings(spec);
EXPECT_GT(first_count, 0);
// recompile — warnings should be regenerated, not accumulated
mj_deleteModel(model);
model = mj_compile(spec, 0);
ASSERT_THAT(model, NotNull());
EXPECT_EQ(mjs_numWarnings(spec), first_count);
mj_deleteModel(model);
mj_deleteSpec(spec);
}
TEST_F(MujocoTest, LoadXMLWarningInErrorBuffer) {
static constexpr char xml[] = R"(
<mujoco>
<worldbody>
<body name="parent">
<geom size="1"/>
<flexcomp name="grid" type="grid" count="3 3 1" spacing="0.1 0.1 0.1"
dim="2" radius="0.01">
<contact internal="false"/>
</flexcomp>
</body>
</worldbody>
</mujoco>
)";
// write xml to VFS
mjVFS vfs;
mj_defaultVFS(&vfs);
mj_addBufferVFS(&vfs, "model.xml", xml, sizeof(xml));
std::array<char, 1024> error;
error[0] = '\0';
mjModel* model = mj_loadXML("model.xml", &vfs, error.data(), error.size());
ASSERT_THAT(model, NotNull());
// warning should be in the error buffer
EXPECT_THAT(error.data(), HasSubstr("not rigid"));
mj_deleteModel(model);
mj_deleteVFS(&vfs);
}
TEST_F(MujocoTest, CompileWarningChainedToHandler) {
static constexpr char xml[] = R"(
<mujoco>
<worldbody>
<body name="parent">
<geom size="1"/>
<flexcomp name="grid" type="grid" count="3 3 1" spacing="0.1 0.1 0.1"
dim="2" radius="0.01">
<contact internal="false"/>
</flexcomp>
</body>
</worldbody>
</mujoco>
)";
std::array<char, 1024> error;
mjSpec* spec = mj_parseXMLString(xml, 0, error.data(), error.size());
ASSERT_THAT(spec, NotNull()) << error.data();
// install a custom log handler that captures warnings
std::vector<std::string> captured_warnings;
static thread_local std::vector<std::string>* capture_ptr = nullptr;
capture_ptr = &captured_warnings;
// install custom log handler (replaces global, so mock is bypassed)
mjfLogHandler prev = mju_setLogHandler([](const mjLogMessage* msg) {
if (msg->level == mjLOG_WARNING && capture_ptr) {
capture_ptr->push_back(msg->subject);
}
});
mjModel* model = mj_compile(spec, 0);
ASSERT_THAT(model, NotNull());
// restore log handler
mju_setLogHandler(prev);
capture_ptr = nullptr;
// chaining should have forwarded warnings to our handler
EXPECT_THAT(captured_warnings, testing::Contains(HasSubstr("not rigid")));
mj_deleteModel(model);
mj_deleteSpec(spec);
}
} // namespace
} // namespace mujoco
+1 -1
View File
@@ -1181,7 +1181,7 @@ TEST_F(MjCGeomTest, IgnoreBadGeomOutsideInertiagrouprange) {
TEST_F(MjCGeomTest, NanSize) {
// even if the caller ignores warnings, models shouldn't compile with NaN
// geom sizes
mju_user_warning = nullptr;
mock_warning_handler.ExpectWarnings();
static constexpr char xml[] = R"(
<mujoco>
<worldbody>
+6
View File
@@ -84,6 +84,12 @@ class WriteReadCompareTest : public XMLWriterTest,
TEST_P(WriteReadCompareTest, WriteReadCompare) {
std::string xml = GetParam();
// If this is the flex_line_obj model, expect the 'is not rigid' warning
if (absl::StrContains(xml, "flex_line_obj")) {
EXPECT_CALL(mock_warning_handler, Warn(testing::HasSubstr("is not rigid")))
.WillRepeatedly(testing::Return());
}
// full precision float printing
FullFloatPrecision increase_precision;
+48 -9
View File
@@ -115,15 +115,20 @@ EMSCRIPTEN_DECLARE_VAL_TYPE(StringOrNull);
mju_error("Invalid argument: %s is undefined", #val); \
}
void ThrowMujocoErrorToJS(const char* msg) {
// Get a handle to the JS global Error constructor function, create a new
// object instance and then throw the object as an exception using the
// val::throw_() helper function.
val(val::global("Error").new_(val("MuJoCo Error: " + std::string(msg))))
.throw_();
void ThrowMujocoErrorToJS(const mjLogMessage* msg) {
if (msg->level == mjLOG_ERROR) {
std::string message = msg->subject;
if (msg->func) {
message = std::string(msg->func) + ": " + msg->subject;
}
// Get a handle to the JS global Error constructor function, create a new
// object instance and then throw the object as an exception using the
// val::throw_() helper function.
val(val::global("Error").new_(val("MuJoCo Error: " + message))).throw_();
}
}
__attribute__((constructor)) void InitMuJoCoErrorHandler() {
mju_user_error = ThrowMujocoErrorToJS;
mju_setLogHandler(ThrowMujocoErrorToJS);
}
// Generates a descriptive error message for when a key lookup fails.
@@ -8730,20 +8735,44 @@ std::unique_ptr<MjSpec> parseXMLString_wrapper(const std::string &xml) {
std::unique_ptr<MjModel> mj_compile_wrapper_1(const MjSpec& spec) {
mjSpec* spec_ptr = spec.get();
// suppress stderr playback: warnings are raised via console.warn() below
mjfLogHandler prev = _mjPRIVATE_setTlsLogHandler([](const mjLogMessage*) {});
mjModel* model = mj_compile(spec_ptr, nullptr);
if (!model || mjs_isWarning(spec_ptr)) {
_mjPRIVATE_setTlsLogHandler(prev);
if (!model) {
mju_error("%s", mjs_getError(spec_ptr));
}
int num_warnings = mjs_numWarnings(spec_ptr);
if (num_warnings > 0) {
for (int i = 0; i < num_warnings; ++i) {
val::global("console").call<void>(
"warn",
val("MuJoCo Warning: " + std::string(mjs_getWarning(spec_ptr, i))));
}
}
return std::unique_ptr<MjModel>(new MjModel(model));
}
std::unique_ptr<MjModel> mj_compile_wrapper_2(const MjSpec& spec, const MjVFS& vfs) {
mjSpec* spec_ptr = spec.get();
mjVFS* vfs_ptr = vfs.get();
// suppress stderr playback: warnings are raised via console.warn() below
mjfLogHandler prev = _mjPRIVATE_setTlsLogHandler([](const mjLogMessage*) {});
mjModel* model = mj_compile(spec_ptr, vfs_ptr);
if (!model || mjs_isWarning(spec_ptr)) {
_mjPRIVATE_setTlsLogHandler(prev);
if (!model) {
mju_error("%s", mjs_getError(spec_ptr));
}
int num_warnings = mjs_numWarnings(spec_ptr);
if (num_warnings > 0) {
for (int i = 0; i < num_warnings; ++i) {
val::global("console").call<void>(
"warn",
val("MuJoCo Warning: " + std::string(mjs_getWarning(spec_ptr, i))));
}
}
return std::unique_ptr<MjModel>(new MjModel(model));
}
@@ -10101,6 +10130,10 @@ std::optional<MjsDefault> mjs_getSpecDefault_wrapper(const MjSpec& s) {
return MjsDefault(result);
}
std::string mjs_getWarning_wrapper(const MjSpec& spec, int index) {
return std::string(mjs_getWarning(spec.get(), index));
}
std::optional<MjsWrap> mjs_getWrap_wrapper(const MjsTendon& tendonspec, int i) {
mjsWrap* result = mjs_getWrap(tendonspec.get(), i);
if (result == nullptr) {
@@ -10178,6 +10211,10 @@ std::optional<MjsElement> mjs_nextElement_wrapper(const MjSpec& s, const MjsElem
return MjsElement(result);
}
int mjs_numWarnings_wrapper(const MjSpec& spec) {
return mjs_numWarnings(spec.get());
}
std::string mjs_resolveOrientation_wrapper(const val& quat, mjtByte degree, const String& sequence, const MjsOrientation& orientation) {
CHECK_VAL(sequence);
UNPACK_VALUE(double, quat);
@@ -13742,6 +13779,7 @@ EMSCRIPTEN_BINDINGS(mujoco_bindings) {
function("mjs_getParent", &mjs_getParent_wrapper);
function("mjs_getSpec", &mjs_getSpec_wrapper);
function("mjs_getSpecDefault", &mjs_getSpecDefault_wrapper);
function("mjs_getWarning", &mjs_getWarning_wrapper);
function("mjs_getWrap", &mjs_getWrap_wrapper);
function("mjs_getWrapCoef", &mjs_getWrapCoef_wrapper);
function("mjs_getWrapDivisor", &mjs_getWrapDivisor_wrapper);
@@ -13753,6 +13791,7 @@ EMSCRIPTEN_BINDINGS(mujoco_bindings) {
function("mjs_makeMesh", &mjs_makeMesh_wrapper);
function("mjs_nextChild", &mjs_nextChild_wrapper);
function("mjs_nextElement", &mjs_nextElement_wrapper);
function("mjs_numWarnings", &mjs_numWarnings_wrapper);
function("mjs_resolveOrientation", &mjs_resolveOrientation_wrapper);
function("mjs_sensorDim", &mjs_sensorDim_wrapper);
function("mjs_setDeepCopy", &mjs_setDeepCopy_wrapper);
+38 -9
View File
@@ -113,15 +113,20 @@ EMSCRIPTEN_DECLARE_VAL_TYPE(StringOrNull);
mju_error("Invalid argument: %s is undefined", #val); \
}
void ThrowMujocoErrorToJS(const char* msg) {
// Get a handle to the JS global Error constructor function, create a new
// object instance and then throw the object as an exception using the
// val::throw_() helper function.
val(val::global("Error").new_(val("MuJoCo Error: " + std::string(msg))))
.throw_();
void ThrowMujocoErrorToJS(const mjLogMessage* msg) {
if (msg->level == mjLOG_ERROR) {
std::string message = msg->subject;
if (msg->func) {
message = std::string(msg->func) + ": " + msg->subject;
}
// Get a handle to the JS global Error constructor function, create a new
// object instance and then throw the object as an exception using the
// val::throw_() helper function.
val(val::global("Error").new_(val("MuJoCo Error: " + message))).throw_();
}
}
__attribute__((constructor)) void InitMuJoCoErrorHandler() {
mju_user_error = ThrowMujocoErrorToJS;
mju_setLogHandler(ThrowMujocoErrorToJS);
}
// Generates a descriptive error message for when a key lookup fails.
@@ -798,20 +803,44 @@ std::unique_ptr<MjSpec> parseXMLString_wrapper(const std::string &xml) {
std::unique_ptr<MjModel> mj_compile_wrapper_1(const MjSpec& spec) {
mjSpec* spec_ptr = spec.get();
// suppress stderr playback: warnings are raised via console.warn() below
mjfLogHandler prev = _mjPRIVATE_setTlsLogHandler([](const mjLogMessage*) {});
mjModel* model = mj_compile(spec_ptr, nullptr);
if (!model || mjs_isWarning(spec_ptr)) {
_mjPRIVATE_setTlsLogHandler(prev);
if (!model) {
mju_error("%s", mjs_getError(spec_ptr));
}
int num_warnings = mjs_numWarnings(spec_ptr);
if (num_warnings > 0) {
for (int i = 0; i < num_warnings; ++i) {
val::global("console").call<void>(
"warn",
val("MuJoCo Warning: " + std::string(mjs_getWarning(spec_ptr, i))));
}
}
return std::unique_ptr<MjModel>(new MjModel(model));
}
std::unique_ptr<MjModel> mj_compile_wrapper_2(const MjSpec& spec, const MjVFS& vfs) {
mjSpec* spec_ptr = spec.get();
mjVFS* vfs_ptr = vfs.get();
// suppress stderr playback: warnings are raised via console.warn() below
mjfLogHandler prev = _mjPRIVATE_setTlsLogHandler([](const mjLogMessage*) {});
mjModel* model = mj_compile(spec_ptr, vfs_ptr);
if (!model || mjs_isWarning(spec_ptr)) {
_mjPRIVATE_setTlsLogHandler(prev);
if (!model) {
mju_error("%s", mjs_getError(spec_ptr));
}
int num_warnings = mjs_numWarnings(spec_ptr);
if (num_warnings > 0) {
for (int i = 0; i < num_warnings; ++i) {
val::global("console").call<void>(
"warn",
val("MuJoCo Warning: " + std::string(mjs_getWarning(spec_ptr, i))));
}
}
return std::unique_ptr<MjModel>(new MjModel(model));
}