google-deepmind / google-deepmind/mujoco
Studio: an exception in one plugin handler terminates the viewer
- Dominant language
- C++
- Stars
- 15.2k
- Forks
- 1.8k
- Avg merge
- 10d 16h
- Merged PRs (30d)
- 25
Description
### Intro
I use MuJoCo for headless physics simulation and offscreen rendering distributed across many workers, and I have been writing a Studio plugin that adds a panel to the viewer.
### My setup
Observed on both:
- MuJoCo 3.13.0 (PyPI wheel), Python 3.11.5, arm64, macOS 26.6.2
- MuJoCo 3.13.0 (PyPI wheel), Python 3.14.4, x86_64, Ubuntu 26.04
API: Python.
### What's happening? What did you expect?
`PluginRegistry.dispatch` calls handlers without isolation:
```python
# python/mujoco/experimental/studio/plugin_registry.py
for _, handler in message_handlers:
if handler(message):
return
```
So an exception raised in any plugin handler propagates out of the viewer loop and
terminates the application. One mistake in one plugin takes down MuJoCo and every
other registered plugin with it.
I expected a failing plugin to be reported and, ideally, isolated, since third
party plugin code is the intended use of this API and a typo in one is the
expected failure.
### Two presentations, depending on which thread the viewer runs on
**Viewer on the main thread** (blocking launcher): a readable Python traceback,
process exits 1.
```
Traceback (most recent call last):
...
File ".../studio/launch_native.py", line 38, in run_native_viewer
viewer_protocol.run_viewer_loop(viewer)
File ".../studio/viewer_protocol.py", line 283, in run_viewer_loop
viewer.dispatch(messages.BuildGuiEvent())
ValueError: a deliberate typo in plugin code
```
**Viewer on a worker thread** (the default `launch_passive` path, observed on
Linux): `SIGABRT` with a native stack and no Python frame, because the exception
escapes into the C++ callback that invoked it.
```
Current thread 0x... [repro.py] (most recent call first):
Garbage-collecting
Current thread's C stack trace (most recent call first):
Binary file ".../python", at _Py_DumpStack+0x4a
...
Binary file ".../libc.so.6", at abort+0x27
Binary file ".../studio/native_viewer_cc...so", at +0x264946
```
The second is considerably harder to diagnose, since nothing indicates which plugin
failed or why. In my case the underlying cause was passing arguments matching the
wrong `imgui.MenuItem` overload, so a `TypeError` from unpacking a `bool`, and the
only signal was a C++ abort.
### Suggested fix
Catch per handler in the dispatch loop, so one plugin cannot take down the
application or its siblings, and attribute the failure:
```python
for _, handler in message_handlers:
try:
if handler(message):
return
except Exception:
logging.exception("plugin handler %r failed handling %s",
handler, type(message).__name__)
```
Swallowing errors has its own cost, so alternatives worth considering: keep the
raise but wrap it with the plugin name and message type, or disable a handler after
it raises, since a `BuildGuiEvent` handler runs every frame and will otherwise
flood the log.
Naming the responsible plugin would help most. Even without isolation, a message
saying which handler raised would have saved a long detour on the abort case.
### Steps for reproduction
1. `pip install mujoco==3.13.0`
2. Save `minimal.xml` and `repro.py` below into the same directory.
3. On Linux: `python repro.py`. The viewer window opens and the process dies with
`SIGABRT` on the first GUI frame.
4. On macOS, `launch_passive` cannot open a window at all (filed separately), so
use a blocking main-thread launcher to reproduce; the failure then presents as
the Python traceback shown above.
### Minimal model for reproduction
Loadable as-is, no binary assets required:
```xml
```
### Code required for reproduction
`repro.py`:
```python
import time
import mujoco
from mujoco.experimental.studio import (launch_passive, messages, step_control,
viewer_app, viewer_protocol)
class Broken:
"""A plugin with an ordinary mistake in its GUI handler."""
name = "broken"
@messages.handler
def on_build_gui(self, event: messages.BuildGuiEvent) -> None:
raise ValueError("a deliberate typo in plugin code")
model = mujoco.MjModel.from_xml_path("minimal.xml")
data = mujoco.MjData(model)
handle = launch_passive.launch_passive(
viewer_protocol.ViewerConfig(title="plugin exception repro", width=900, height=600),
viewer_plugins=[viewer_app.ViewerApp(), Broken()],
sim_plugins=[step_control.StepControl()],
)
handle.send_to_viewer(messages.ModelEvent(model=model, path="minimal.xml"))
for _ in range(500):
model, data = handle.sync(model, data)
time.sleep(0.02)
print("finished normally", flush=True) # not reached
```
Removing the `raise` from `on_build_gui` makes the same script run to completion,
which isolates the exception as the cause.
### Confirmations
- [x] I searched the [latest documentation](https://mujoco.readthedocs.io/en/latest/overview.html) thoroughly before posting.
- [x] I searched previous [Issues](https://github.com/google-deepmind/mujoco/issues) and [Discussions](https://github.com/google-deepmind/mujoco/discussions), I am certain this has not been raised before.
Contributor guide
Research direction
Start in python/mujoco/experimental/studio/plugin_registry.py at PluginRegistry.dispatch, then run repro.py with the supplied minimal.xml to observe the handler failure. Trace how viewer_protocol.py and launch_passive invoke dispatch. Done means a plugin exception no longer terminates the viewer and the failure identifies the handler or plugin clearly.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- desktop
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100