Refactor viewer loop and shutdown handling to support conditional rendering and clean exits.

This change introduces a get_frame method to the viewer protocol, replacing the previous is_running check. This allows the viewer loop to skip rendering and lifecycle events when no active frame is ready (for example, when no browser is connected to the web viewer).

Additionally, this change:
- Adds a shutdown hook to ViewerHandle to wait for the viewer to cleanly release resources before exiting.
- Implements an ExitEvent handler in the viewer protocol to stop the run loop.
- Adds an http_port configuration option for the web viewer.
- Prevents double-closing in the viewer.

PiperOrigin-RevId: 954603868
Change-Id: Iaba7d131dc94191de51150464514dd10fa7834b0
This commit is contained in:
Matija Kecman
2026-07-27 06:54:26 -07:00
committed by Copybara-Service
parent e23b504201
commit e4d0a88ffd
4 changed files with 68 additions and 24 deletions
@@ -21,6 +21,15 @@ from mujoco.experimental.studio import messages
from mujoco.experimental.studio import sim as _sim
import numpy as np
# Launcher-owned liveness check: returns True while the viewer is still alive.
# This enables the handle to notice a viewer died without sending ExitEvent.
IsAliveFn = Callable[[], bool]
# Launcher-owned shutdown function: waits up to the given timeout (seconds) for
# the viewer to finish after close() has sent the ExitEvent. Waiting lets the
# viewer release its resources before the interpreter tears itself down.
ShutdownFn = Callable[[float], None]
class ViewerHandle:
"""A handle for interacting with a running Studio application from the sim."""
@@ -30,7 +39,8 @@ class ViewerHandle:
sim_endpoint: endpoints.SimEndpoint,
*,
handlers: list[Any] | None = None,
is_alive_fn: Callable[[], bool] | None = None,
is_alive_fn: IsAliveFn | None = None,
shutdown_fn: ShutdownFn | None = None,
) -> None:
"""Initializes the ViewerHandle.
@@ -38,14 +48,15 @@ class ViewerHandle:
sim_endpoint: The endpoint to use for communication with the viewer.
handlers: Optional list of handler instances for sim-side processing,
which are classes with methods decorated with ``@handler``.
is_alive_fn: Optional function called to check if the viewer is still
alive/responsive. If not provided, the viewer is assumed to be running
until ``close()`` is called.
is_alive_fn: Optional liveness check; without one the viewer is assumed to
be running until ``close()`` is called.
shutdown_fn: Optional launcher-owned shutdown hook, called by ``close()``.
"""
self._sim_endpoint = sim_endpoint
self._is_running = True
self._is_alive_fn = is_alive_fn
self._shutdown_fn = shutdown_fn
self.model: mujoco.MjModel | None = None
self.data: mujoco.MjData | None = None
self.step_control: _sim.StepControl | None = None
@@ -56,13 +67,15 @@ class ViewerHandle:
self._handlers = handler_registry.HandlerRegistry(all_handlers)
def close(self) -> None:
"""Signals the viewer to exit and closes the sim endpoint."""
"""Signals the viewer to exit and waits for it to shut down."""
if self._is_running:
self._is_running = False
try:
self.send_to_viewer(messages.ExitEvent())
except Exception: # pylint: disable=broad-exception-caught
pass # Ignore exceptions, the viewer may have already closed.
if self._shutdown_fn is not None:
self._shutdown_fn(5.0)
self._sim_endpoint.close()
def __enter__(self) -> 'ViewerHandle':