Introduce new logging API, fixes #858

PiperOrigin-RevId: 930744288
Change-Id: I6ec1203b55c031390f3eef23192e2337508ce886
This commit is contained in:
Yuval Tassa
2026-06-11 14:36:17 -07:00
committed by Copybara-Service
parent a2abaf7aef
commit 58f6d52491
45 changed files with 2586 additions and 422 deletions
+49
View File
@@ -17,6 +17,7 @@
import contextlib
import copy
from etils import epath
import os
import pickle
import sys
@@ -511,6 +512,54 @@ class MuJoCoBindingsTest(parameterized.TestCase):
mujoco.mj_checkPos(self.model, self.data)
self.assertEqual(warnings[mujoco.mjtWarning.mjWARN_BADQPOS].number, 1)
def test_mju_user_warning_callback_receives_warnings(self):
"""Regression test: C warnings must reach Python mju_user_warning callbacks.
The unified logging API routes all messages through a TLS log handler.
This test verifies that non-error messages are forwarded to the global
handler chain, where legacy mju_user_warning callbacks are invoked.
"""
warning_messages = []
def warning_cb(msg):
warning_messages.append(msg)
old_cb = mujoco.get_mju_user_warning()
try:
mujoco.set_mju_user_warning(warning_cb)
# Trigger a C-level warning by setting qpos to NaN and calling mj_step.
model = mujoco.MjModel.from_xml_string(TEST_XML)
data = mujoco.MjData(model)
data.qpos[0] = float('NaN')
mujoco.mj_checkPos(model, data)
self.assertNotEmpty(warning_messages)
# The warning message should mention the bad QPOS value.
self.assertTrue(
any('QPOS' in msg for msg in warning_messages), warning_messages
)
finally:
mujoco.set_mju_user_warning(old_cb)
def test_mjtopic_time_cmp_logs_compile_time(self):
"""Verifies MjLogConfig.get/set and info topic logging."""
old_cfg = mujoco.MjLogConfig.get()
log_path = os.path.join(
absltest.get_default_test_tmpdir(), 'test_compile.log'
)
os.makedirs(os.path.dirname(log_path), exist_ok=True)
try:
cfg = mujoco.MjLogConfig.get()
cfg.logto_file = True
cfg.logfile = log_path
cfg.topics |= (1 << (mujoco.mjtLogTopic.mjTOPIC_TIME_CMP - 1))
cfg.set()
mujoco.MjModel.from_xml_string(TEST_XML)
with open(log_path, 'r') as f:
output = f.read()
self.assertIn('compile time', output)
finally:
old_cfg.set()
def test_mjcontact_can_copy(self):
mujoco.mj_forward(self.model, self.data)
@@ -192,6 +192,7 @@ _ALLOWED_FIXED_ARRAYS = {
_FUNCTION_POINTER_TYPES = {
'mjfItemEnable',
'mjfLogHandler',
}
_STRUCT_NAME_OVERRIDES = {}
@@ -37,10 +37,8 @@ def main(argv: Sequence[str]) -> None:
struct_decls = []
for func in FUNCTIONS.values():
# Skip mju_error_{i,s} and mju_warning_{i,s} as these are not
# supported in the Python bindings, and Introspect currently
# doesn't support variadic functions.
if func.name.startswith('mju_error') or func.name == 'mju_warning':
# Skip variadic functions as Introspect currently doesn't support them.
if func.name in ('mju_error', 'mju_warning', 'mju_info'):
continue
# Modify some parameter types.
+43 -8
View File
@@ -15,12 +15,15 @@
#ifndef MUJOCO_PYTHON_ERRORS_H_
#define MUJOCO_PYTHON_ERRORS_H_
#include <array>
#include <csetjmp>
#include <cstdio>
#include <cstring>
#include <stdexcept>
#include <string>
#include <type_traits>
#include <mujoco/mjexport.h>
#include <mujoco/mjtype.h>
#include "private.h"
#include "util/crossplatform.h"
#include "util/func_wrap.h"
@@ -104,9 +107,28 @@ class ErrorBase : public pybind11::builtin_exception {
static thread_local std::jmp_buf mju_error_jmp_buf;
static thread_local std::array<char, 1024> mju_error_msg{0};
static inline void MjErrorHandler(const char* msg) {
std::strncpy(mju_error_msg.data(), msg, mju_error_msg.size() - 1);
mju_error_msg.data()[mju_error_msg.size() - 1] = '\0';
// The handler to forward non-error messages to. Set by WrapFunc before each
// call into MuJoCo C code, pointing to either the previously installed TLS
// handler or the active global handler.
static thread_local mjfLogHandler mju_forward_handler = nullptr;
static inline void MjErrorHandler(const mjLogMessage* msg) {
if (msg->level != mjLOG_ERROR) {
// Forward warnings, info, and debug messages to the previous handler so
// that legacy mju_user_warning callbacks and console output continue to
// work.
if (mju_forward_handler != nullptr) {
mju_forward_handler(msg);
}
return;
}
if (msg->func != nullptr) {
std::snprintf(mju_error_msg.data(), mju_error_msg.size(), "%s: %s",
msg->func, msg->subject);
} else {
std::strncpy(mju_error_msg.data(), msg->subject, mju_error_msg.size() - 1);
mju_error_msg.data()[mju_error_msg.size() - 1] = '\0';
}
std::longjmp(mju_error_jmp_buf, 1);
}
@@ -121,7 +143,17 @@ struct MjErrorIntercepter {
#else
return [callable](Args... args) MUJOCO_ALWAYS_INLINE_LAMBDA_MUTABLE {
#endif
_mjPRIVATE__set_tls_error_fn(&MjErrorHandler);
mjfLogHandler prev_handler = _mjPRIVATE_setTlsLogHandler(&MjErrorHandler);
// Determine the handler to forward non-error messages to.
// If there was a previously installed TLS handler, forward to it.
// Otherwise, probe the global handler via mju_setLogHandler.
mjfLogHandler old_forward = mju_forward_handler;
if (prev_handler != nullptr) {
mju_forward_handler = prev_handler;
} else {
mju_forward_handler = _mjPRIVATE_getGlobalLogHandler();
}
// DON'T MIX RAII WITH SETJMP!
// From https://en.cppreference.com/w/cpp/utility/program/longjmp:
@@ -131,16 +163,19 @@ struct MjErrorIntercepter {
if (setjmp(mju_error_jmp_buf) == 0) {
if constexpr (std::is_void_v<decltype(callable(args...))>) {
callable(args...);
_mjPRIVATE__set_tls_error_fn(nullptr);
mju_forward_handler = old_forward;
_mjPRIVATE_setTlsLogHandler(prev_handler);
} else {
auto ret = callable(args...);
static_assert(std::is_trivially_destructible_v<decltype(ret)>);
_mjPRIVATE__set_tls_error_fn(nullptr);
mju_forward_handler = old_forward;
_mjPRIVATE_setTlsLogHandler(prev_handler);
return ret;
}
} else {
// This branch is entered via a longjmp back from our mju_error handler.
_mjPRIVATE__set_tls_error_fn(nullptr);
mju_forward_handler = old_forward;
_mjPRIVATE_setTlsLogHandler(prev_handler);
{
// Check if a Python callback has thrown an exception.
// We cannot use a py::gil_scoped_acquire here: on Windows its
+23
View File
@@ -609,6 +609,29 @@ ENUMS: Mapping[str, EnumDecl] = dict([
('mjS_AWAKE', 1),
]),
)),
('mjtLogLevel',
EnumDecl(
name='mjtLogLevel',
declname='enum mjtLogLevel_',
values=dict([
('mjLOG_DEBUG', 0),
('mjLOG_INFO', 1),
('mjLOG_WARNING', 2),
('mjLOG_ERROR', 3),
]),
)),
('mjtLogTopic',
EnumDecl(
name='mjtLogTopic',
declname='enum mjtLogTopic_',
values=dict([
('mjTOPIC_NONE', 0),
('mjTOPIC_TIME_STP', 1),
('mjTOPIC_TIME_CMP', 2),
('mjTOPIC_SLEEP', 3),
('mjNTOPIC', 3),
]),
)),
('mjtGeomInertia',
EnumDecl(
name='mjtGeomInertia',
+63
View File
@@ -6309,6 +6309,69 @@ FUNCTIONS: Mapping[str, FunctionDecl] = dict([
parameters=(),
doc='Clear user error and memory handlers.',
)),
('mju_setLogHandler',
FunctionDecl(
name='mju_setLogHandler',
return_type=ValueType(name='mjfLogHandler'),
parameters=(
FunctionParameterDecl(
name='handler',
type=ValueType(name='mjfLogHandler'),
),
),
doc='Set the active log handler; return the previous handler. If handler is NULL, restore the default handler.', # pylint: disable=line-too-long
)),
('mju_getLogConfig',
FunctionDecl(
name='mju_getLogConfig',
return_type=ValueType(name='mjLogConfig'),
parameters=(),
doc='Get default handler configuration.',
)),
('mju_setLogConfig',
FunctionDecl(
name='mju_setLogConfig',
return_type=ValueType(name='void'),
parameters=(
FunctionParameterDecl(
name='config',
type=ValueType(name='mjLogConfig'),
),
),
doc='Set default handler configuration.',
)),
('mju_info',
FunctionDecl(
name='mju_info',
return_type=ValueType(name='void'),
parameters=(
FunctionParameterDecl(
name='topic',
type=ValueType(name='int'),
),
FunctionParameterDecl(
name='msg',
type=PointerType(
inner_type=ValueType(name='char', is_const=True),
),
),
),
doc='Log an info message with optional topic filtering.',
)),
('mju_message',
FunctionDecl(
name='mju_message',
return_type=ValueType(name='void'),
parameters=(
FunctionParameterDecl(
name='msg',
type=PointerType(
inner_type=ValueType(name='mjLogMessage', is_const=True),
),
),
),
doc='Dispatch a structured log message to the active handler.',
)),
('mju_malloc',
FunctionDecl(
name='mju_malloc',
+86
View File
@@ -28,6 +28,92 @@ from .ast_nodes import StructFieldDecl
from .ast_nodes import ValueType
STRUCTS: Mapping[str, StructDecl] = dict([
('mjLogMessage',
StructDecl(
name='mjLogMessage',
declname='struct mjLogMessage_',
fields=(
StructFieldDecl(
name='level',
type=ValueType(name='int'),
doc='mjtLogLevel',
),
StructFieldDecl(
name='topic',
type=ValueType(name='int'),
doc='mjtLogTopic (0 for error/warning/user)',
),
StructFieldDecl(
name='subject',
type=ArrayType(
inner_type=ValueType(name='char'),
extents=(1024,),
),
doc='message subject (one-liner, printf-formatted)',
),
StructFieldDecl(
name='body',
type=PointerType(
inner_type=ValueType(name='char', is_const=True),
),
doc='message body (multi-line detail, or NULL)',
),
StructFieldDecl(
name='func',
type=PointerType(
inner_type=ValueType(name='char', is_const=True),
),
doc='__func__ or NULL',
),
StructFieldDecl(
name='file',
type=PointerType(
inner_type=ValueType(name='char', is_const=True),
),
doc='__FILE__ or NULL',
),
StructFieldDecl(
name='line',
type=ValueType(name='int'),
doc='__LINE__ or 0',
),
StructFieldDecl(
name='timestamp',
type=ValueType(name='mjtBool'),
doc='prepend timestamp to output',
),
),
)),
('mjLogConfig',
StructDecl(
name='mjLogConfig',
declname='struct mjLogConfig_',
fields=(
StructFieldDecl(
name='logto_console',
type=ValueType(name='mjtBool'),
doc='print to console (default: true)',
),
StructFieldDecl(
name='logto_file',
type=ValueType(name='mjtBool'),
doc='print to log file (default: true)',
),
StructFieldDecl(
name='logfile',
type=ArrayType(
inner_type=ValueType(name='char'),
extents=(1024,),
),
doc='log file path (default: "MUJOCO_LOG.TXT")',
),
StructFieldDecl(
name='topics',
type=ValueType(name='int'),
doc='enabled info topic bitmask (default: 0)',
),
),
)),
('mjLROpt',
StructDecl(
name='mjLROpt',
+3 -1
View File
@@ -17,11 +17,13 @@
#include <mujoco/mjdata.h>
#include <mujoco/mjexport.h>
#include <mujoco/mjtype.h>
// DO NOT USE THESE FUNCTIONS ELSEWHERE.
// They should be regarded as part of MuJoCo's internal implementation detail.
extern "C" {
MJAPI void _mjPRIVATE__set_tls_error_fn(void (*h)(const char*));
MJAPI mjfLogHandler _mjPRIVATE_setTlsLogHandler(mjfLogHandler handler);
MJAPI mjfLogHandler _mjPRIVATE_getGlobalLogHandler(void);
MJAPI void* mj_arenaAllocByte(mjData* d, int bytes, int alignment);
}
+3
View File
@@ -19,6 +19,7 @@
#include <mujoco/mjmodel.h>
#include <mujoco/mjrender.h>
#include <mujoco/mjspec.h>
#include <mujoco/mjtype.h>
#include <mujoco/mjvisualize.h>
// Type aliases for MuJoCo C structs to allow us refer to consistently refer
@@ -72,6 +73,8 @@ using MjVisualMap = decltype(::mjVisual::map);
using MjVisualScale = decltype(::mjVisual::scale);
using MjVisualRgba = decltype(::mjVisual::rgba);
using MjWarningStat = ::mjWarningStat;
using MjLogConfig = ::mjLogConfig;
using MjLogMessage = ::mjLogMessage;
// From mjrender.h
using MjrRect = ::mjrRect;
+99 -5
View File
@@ -539,6 +539,96 @@ This is useful for example when the MJB is not available as a file on disk.)"));
X(int, number);
#undef X
// ==================== MJLOGCONFIG ==========================================
py::class_<MjLogConfigWrapper> mjLogConfig(m, "MjLogConfig");
mjLogConfig.def(py::init<>());
mjLogConfig.def("__copy__", [](const MjLogConfigWrapper& other) {
return MjLogConfigWrapper(other);
});
mjLogConfig.def("__deepcopy__",
[](const MjLogConfigWrapper& other, py::dict) {
return MjLogConfigWrapper(other);
});
DefineStructFunctions(mjLogConfig);
mjLogConfig.def_property(
"logto_console",
[](const MjLogConfigWrapper& d) { return d.get()->logto_console; },
[](MjLogConfigWrapper& d, bool rhs) { d.get()->logto_console = rhs; });
mjLogConfig.def_property(
"logto_file",
[](const MjLogConfigWrapper& d) { return d.get()->logto_file; },
[](MjLogConfigWrapper& d, bool rhs) { d.get()->logto_file = rhs; });
mjLogConfig.def_property(
"logfile",
[](const MjLogConfigWrapper& d) { return std::string(d.get()->logfile); },
[](MjLogConfigWrapper& d, const std::string& rhs) {
std::strncpy(d.get()->logfile, rhs.c_str(), 1023);
d.get()->logfile[1023] = '\0';
});
mjLogConfig.def_property(
"topics", [](const MjLogConfigWrapper& d) { return d.get()->topics; },
[](MjLogConfigWrapper& d, int rhs) { d.get()->topics = rhs; });
mjLogConfig.def_static("get", []() {
MjLogConfigWrapper wrapper;
*wrapper.get() = mju_getLogConfig();
return wrapper;
});
mjLogConfig.def("set", [](const MjLogConfigWrapper& self) {
mju_setLogConfig(*self.get());
});
// ==================== MJLOGMESSAGE =========================================
py::class_<MjLogMessageWrapper> mjLogMessage(m, "MjLogMessage");
mjLogMessage.def(py::init<>());
mjLogMessage.def("__copy__", [](const MjLogMessageWrapper& other) {
return MjLogMessageWrapper(other);
});
mjLogMessage.def("__deepcopy__",
[](const MjLogMessageWrapper& other, py::dict) {
return MjLogMessageWrapper(other);
});
DefineStructFunctions(mjLogMessage);
mjLogMessage.def_property(
"level", [](const MjLogMessageWrapper& d) { return d.get()->level; },
[](MjLogMessageWrapper& d, int rhs) { d.get()->level = rhs; });
mjLogMessage.def_property(
"topic", [](const MjLogMessageWrapper& d) { return d.get()->topic; },
[](MjLogMessageWrapper& d, int rhs) { d.get()->topic = rhs; });
mjLogMessage.def_property(
"subject",
[](const MjLogMessageWrapper& d) {
return std::string(d.get()->subject);
},
[](MjLogMessageWrapper& d, const std::string& rhs) {
std::strncpy(d.get()->subject, rhs.c_str(), 1023);
d.get()->subject[1023] = '\0';
});
mjLogMessage.def_property_readonly(
"body",
[](const MjLogMessageWrapper& d) -> py::object {
if (d.get()->body) return py::str(d.get()->body);
return py::none();
});
mjLogMessage.def_property_readonly(
"func",
[](const MjLogMessageWrapper& d) -> py::object {
if (d.get()->func) return py::str(d.get()->func);
return py::none();
});
mjLogMessage.def_property_readonly(
"file",
[](const MjLogMessageWrapper& d) -> py::object {
if (d.get()->file) return py::str(d.get()->file);
return py::none();
});
mjLogMessage.def_property(
"line", [](const MjLogMessageWrapper& d) { return d.get()->line; },
[](MjLogMessageWrapper& d, int rhs) { d.get()->line = rhs; });
mjLogMessage.def_property(
"timestamp",
[](const MjLogMessageWrapper& d) { return d.get()->timestamp; },
[](MjLogMessageWrapper& d, bool rhs) { d.get()->timestamp = rhs; });
// ==================== MJTIMERSTAT ==========================================
py::class_<MjTimerStatWrapper> mjTimerStat(m, "MjTimerStat");
mjTimerStat.def(py::init<>());
@@ -960,18 +1050,22 @@ This is useful for example when the MJB is not available as a file on disk.)"));
#undef X
// ==================== MJRVERTEXATTRIBUTE ===================================
py::class_<raw::MjrVertexAttribute> mjrVertexAttribute(m, "MjrVertexAttribute");
py::class_<raw::MjrVertexAttribute> mjrVertexAttribute(m,
"MjrVertexAttribute");
mjrVertexAttribute.def(py::init([](int usage, int type) {
return raw::MjrVertexAttribute{nullptr, usage, type};
}),
py::arg("usage") = 0, py::arg("type") = 0);
mjrVertexAttribute.def("__copy__",
[](const raw::MjrVertexAttribute& other) { return raw::MjrVertexAttribute(other); });
mjrVertexAttribute.def("__deepcopy__", [](const raw::MjrVertexAttribute& other, py::dict) {
mjrVertexAttribute.def("__copy__", [](const raw::MjrVertexAttribute& other) {
return raw::MjrVertexAttribute(other);
});
mjrVertexAttribute.def("__deepcopy__",
[](const raw::MjrVertexAttribute& other, py::dict) {
return raw::MjrVertexAttribute(other);
});
DefineStructFunctions(mjrVertexAttribute);
#define X(var) mjrVertexAttribute.def_readwrite(#var, &raw::MjrVertexAttribute::var)
#define X(var) \
mjrVertexAttribute.def_readwrite(#var, &raw::MjrVertexAttribute::var)
X(usage);
X(type);
#undef X
+34
View File
@@ -377,6 +377,38 @@ struct is_mj_struct_list<raw::MjWarningStat> {
static constexpr bool value = true;
};
// ==================== MJLOGCONFIG ============================================
template <>
class MjWrapper<raw::MjLogConfig> : public WrapperBase<raw::MjLogConfig> {
public:
MjWrapper();
MjWrapper(const MjWrapper&);
MjWrapper(MjWrapper&&) = default;
MjWrapper(raw::MjLogConfig* ptr, pybind11::handle owner);
~MjWrapper() = default;
};
using MjLogConfigWrapper = MjWrapper<raw::MjLogConfig>;
template <>
struct enable_if_mj_struct<raw::MjLogConfig> { using type = void; };
// ==================== MJLOGMESSAGE ===========================================
template <>
class MjWrapper<raw::MjLogMessage> : public WrapperBase<raw::MjLogMessage> {
public:
MjWrapper();
MjWrapper(const MjWrapper&);
MjWrapper(MjWrapper&&) = default;
MjWrapper(raw::MjLogMessage* ptr, pybind11::handle owner);
~MjWrapper() = default;
};
using MjLogMessageWrapper = MjWrapper<raw::MjLogMessage>;
template <>
struct enable_if_mj_struct<raw::MjLogMessage> { using type = void; };
// ==================== MJTIMERSTAT ============================================
template <>
class MjWrapper<raw::MjTimerStat> : public WrapperBase<raw::MjTimerStat> {
@@ -977,6 +1009,8 @@ using _impl::MjVisualRgbaWrapper;
using _impl::MjVisualWrapper;
using _impl::MjStatisticWrapper;
using _impl::MjWarningStatWrapper;
using _impl::MjLogConfigWrapper;
using _impl::MjLogMessageWrapper;
using _impl::MjTimerStatWrapper;
using _impl::MjSolverStatWrapper;
using _impl::MjModelWrapper;
+22
View File
@@ -1019,6 +1019,28 @@ MjWarningStatList::MjStructList(MjWarningStatList& other, py::slice slice)
: StructListBase(other, slice), X(int, lastinfo), X(int, number) {}
#undef X
// ==================== MJLOGCONFIG ============================================
MjLogConfigWrapper::MjWrapper() : WrapperBase(new raw::MjLogConfig{}) {}
MjLogConfigWrapper::MjWrapper(raw::MjLogConfig* ptr, py::handle owner)
: WrapperBase(ptr, owner) {}
MjLogConfigWrapper::MjWrapper(const MjLogConfigWrapper& other)
: MjLogConfigWrapper() {
*this->ptr_ = *other.ptr_;
}
// ==================== MJLOGMESSAGE ===========================================
MjLogMessageWrapper::MjWrapper() : WrapperBase(new raw::MjLogMessage{}) {}
MjLogMessageWrapper::MjWrapper(raw::MjLogMessage* ptr, py::handle owner)
: WrapperBase(ptr, owner) {}
MjLogMessageWrapper::MjWrapper(const MjLogMessageWrapper& other)
: MjLogMessageWrapper() {
*this->ptr_ = *other.ptr_;
}
// ==================== MJTIMERSTAT ============================================
MjTimerStatWrapper::MjWrapper() : WrapperBase(new raw::MjTimerStat{}) {}