Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions source/isaaclab/isaaclab/sim/simulation_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -693,7 +693,13 @@ def update_visualizers(self, dt: float) -> None:
logger.info("Visualizer not running: %s", type(viz).__name__)
visualizers_to_remove.append(viz)
continue
# Treat Kit transport pause as a simulation-level pause trigger.
# Intentionally no auto-resume bridge: unpause should be explicit.
if type(viz).__name__ == "KitVisualizer" and viz.is_training_paused() and self.is_playing():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Fragile string-based type check

type(viz).__name__ == "KitVisualizer" bypasses Python's type system. This will silently fail for any subclass of KitVisualizer (since the class name would differ), and the string is a typo-prone magic constant with no import-time validation.

A more idiomatic and robust approach is to add a capability method on BaseVisualizer (e.g. needs_transport_pause_bridge() -> bool, returning False by default) and override it to return True in KitVisualizer. That way the dispatch is polymorphic, avoids string comparison, and scales to future visualizer types without modifying simulation_context.py.

self.pause()
if viz.is_rendering_paused():
# Keep polling viewer/UI events while rendering is paused so users can unpause.
viz.step(0.0)
continue
Comment on lines 700 to 703
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 step() inside rendering-pause branch silently discards concurrent user pauses

KitVisualizer.step() always ends by calling settings.set_bool("/app/player/playSimulations", True), unconditionally restoring the play flag. If the user clicks the "Pause Simulation" button during the app.update() call inside step(), the resulting flag-flip to False is immediately overwritten by the restore back to True. Because the is_training_paused() guard at the top of this loop already ran before step() was entered, that protect-pause window is missed entirely.

Concretely:

  1. is_training_paused()False (play flag is True) — KitVisualizer check is skipped.
  2. is_rendering_paused()True — enters this branch.
  3. viz.step(0.0) → sets flag False, calls app.update() (user presses Pause here), restores flag to True — pause discarded.
  4. Next iteration: is_training_paused()False again; pause never propagates.

Before this PR the rendering-pause branch simply called continue without invoking step(), so this path did not exist. Consider re-checking is_training_paused() after viz.step(0.0) and calling self.pause() at that point if needed, or restructuring step() not to restore the flag when it was set by the user rather than by step() itself.

while viz.is_training_paused() and viz.is_running():
viz.step(0.0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,14 @@ def step(self, dt: float) -> None:
self._step_counter += 1
try:
import omni.kit.app

from isaaclab.app.settings_manager import get_settings_manager

app = omni.kit.app.get_app()
if app is not None and app.is_running():
# Keep app pumping for viewport/UI updates only.
# Simulation stepping is owned by SimulationContext.
# Simulation stepping/pause state is owned by SimulationContext.
# Temporarily disable Kit transport play to prevent app.update()
# from advancing PhysX during visualizer-only pumping.
settings = get_settings_manager()
settings.set_bool("/app/player/playSimulations", False)
app.update()
Expand Down
1 change: 1 addition & 0 deletions source/isaaclab_visualizers/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"newton",
"PyOpenGL-accelerate",
"imgui-bundle>=1.92.5",
"munch",
],
"rerun": [
"newton",
Expand Down
107 changes: 106 additions & 1 deletion source/isaaclab_visualizers/test/test_visualizer_smoke_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@

import logging
import socket
import copy

import numpy as np
import pytest
import torch
from isaaclab_visualizers.kit import KitVisualizer, KitVisualizerCfg
Expand All @@ -30,6 +32,8 @@
CartpolePhysicsCfg,
CartpoleSceneCfg,
)
from isaaclab_tasks.direct.cartpole.cartpole_camera_env import CartpoleCameraEnv
from isaaclab_tasks.direct.cartpole.cartpole_camera_presets_env_cfg import CartpoleCameraPresetsEnvCfg

# Set to False to only fail on visualizer errors; when True, also fail on warnings.
ASSERT_VISUALIZER_WARNINGS = True
Expand Down Expand Up @@ -93,7 +97,8 @@ def _get_visualizer_cfg(visualizer_kind: str):
if visualizer_kind == "viser":
__import__("newton")
__import__("viser")
return ViserVisualizerCfg(open_browser=False), ViserVisualizer
port = _find_free_tcp_port(host="127.0.0.1")
return ViserVisualizerCfg(open_browser=False, port=port), ViserVisualizer
Comment on lines +100 to +101
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 TOCTOU race condition in port allocation

_find_free_tcp_port() works by binding to port 0, reading the OS-assigned port, then releasing the socket. Between that release and the viser server's actual bind(), another process on the system can claim the port, causing a ConnectionError or silent fall-back to a default port. The same race applies to the rerun ports allocated in _allocate_rerun_test_ports.

The viser application would ideally accept a socket.socket object that is already bound (keeping the kernel reservation alive), but that requires upstream support. A pragmatic mitigation until then is a retry loop in the test harness. This is worth noting as a known flakiness source in CI environments with many parallel jobs.

if visualizer_kind == "rerun":
__import__("newton")
__import__("rerun")
Expand Down Expand Up @@ -215,6 +220,52 @@ def _run_smoke_test(cfg, expected_visualizer_cls, expected_backend: str, caplog)
SimulationContext.clear_instance()


def _assert_non_black_tensor(image_tensor: torch.Tensor, *, min_nonzero_pixels: int = 1) -> None:
"""Assert camera-like tensor contains non-black pixels."""
assert isinstance(image_tensor, torch.Tensor), f"Expected torch.Tensor, got {type(image_tensor)!r}"
assert image_tensor.numel() > 0, "Image tensor is empty."
finite_tensor = torch.where(torch.isfinite(image_tensor), image_tensor, torch.zeros_like(image_tensor))
if finite_tensor.dtype.is_floating_point:
nonzero = torch.count_nonzero(torch.abs(finite_tensor) > 1e-6).item()
else:
nonzero = torch.count_nonzero(finite_tensor > 0).item()
assert nonzero >= min_nonzero_pixels, "Rendered frame appears black (no non-zero pixels)."


def _assert_non_black_frame_array(frame) -> None:
"""Assert viewer-captured frame has visible, non-black content."""
frame_arr = np.asarray(frame)
assert frame_arr.size > 0, "Viewer returned an empty frame."
if frame_arr.ndim == 2:
color = frame_arr
else:
assert frame_arr.shape[-1] >= 3, f"Expected at least 3 channels, got shape {frame_arr.shape}."
color = frame_arr[..., :3]
finite = np.where(np.isfinite(color), color, 0)
assert np.count_nonzero(finite) > 0, "Viewer frame appears fully black."


def _make_cartpole_camera_env(visualizer_kind: str, backend_kind: str) -> CartpoleCameraEnv:
"""Create cartpole camera env configured with selected visualizer and physics backend."""
env_cfg_root = CartpoleCameraPresetsEnvCfg()
# PresetCfg wrappers may expose concrete presets either on the instance or class.
env_cfg = getattr(env_cfg_root, "default", None)
if env_cfg is None:
env_cfg = getattr(type(env_cfg_root), "default", None)
if env_cfg is None:
raise RuntimeError(
"CartpoleCameraPresetsEnvCfg does not expose a 'default' preset config. "
f"Available attributes: {sorted(vars(env_cfg_root).keys())}"
)
env_cfg = copy.deepcopy(env_cfg)
env_cfg.scene.num_envs = 1
env_cfg.seed = 42
env_cfg.sim.physics, _ = _get_physics_cfg(backend_kind)
visualizer_cfg, _ = _get_visualizer_cfg(visualizer_kind)
env_cfg.sim.visualizer_cfgs = visualizer_cfg
return CartpoleCameraEnv(env_cfg)
Comment on lines +248 to +266
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _make_cartpole_camera_env silently succeeds with wrong config if default attribute is a non-config value

The function looks up env_cfg_root.default (or type(env_cfg_root).default) and deep-copies it. If a future refactor of CartpoleCameraPresetsEnvCfg adds a default attribute that is not an env-cfg object (e.g., a string label or integer), copy.deepcopy will succeed, subsequent attribute assignments (env_cfg.scene.num_envs = 1) will raise an opaque AttributeError, and the RuntimeError guard will never fire.

Adding a isinstance check after the lookup would make the failure mode explicit and easier to diagnose:

if not isinstance(env_cfg, DirectRLEnvCfg):
    raise RuntimeError(
        f"CartpoleCameraPresetsEnvCfg.default is {type(env_cfg)!r}, expected a DirectRLEnvCfg subclass."
    )



@pytest.mark.isaacsim_ci
@pytest.mark.parametrize("visualizer_kind", ["kit", "newton", "rerun", "viser"])
@pytest.mark.parametrize("backend_kind", ["physx", "newton"])
Expand All @@ -224,5 +275,59 @@ def test_visualizer_backend_smoke(visualizer_kind: str, backend_kind: str, caplo
_run_smoke_test(cfg, expected_viz_cls, expected_backend, caplog)


@pytest.mark.isaacsim_ci
@pytest.mark.parametrize("visualizer_kind", ["kit", "newton", "rerun", "viser"])
@pytest.mark.parametrize("backend_kind", ["physx", "newton"])
def test_cartpole_visualizer_non_black_camera_frame(visualizer_kind: str, backend_kind: str):
"""Cartpole tiled-camera output should not be black when visualizers are enabled."""
env = None
try:
sim_utils.create_new_stage()
env = _make_cartpole_camera_env(visualizer_kind=visualizer_kind, backend_kind=backend_kind)
env.sim._app_control_on_stop_handle = None # type: ignore[attr-defined]
env.reset()
actions = torch.zeros((env.num_envs, env.action_space.shape[-1]), device=env.device)
for _ in range(_SMOKE_STEPS):
env.step(action=actions)
rgb = env._tiled_camera.data.output["rgb"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Access to private attribute _tiled_camera

env._tiled_camera is a private (name-mangled by convention) attribute. If the CartpoleCameraEnv implementation renames or restructures this attribute, the test will raise an AttributeError at assertion time rather than at the step that would surface a black-frame regression. Consider either exposing a stable public property on CartpoleCameraEnv, or at minimum adding a guard:

Suggested change
rgb = env._tiled_camera.data.output["rgb"]
tiled_camera = getattr(env, "_tiled_camera", None)
assert tiled_camera is not None, "CartpoleCameraEnv does not expose _tiled_camera."
rgb = tiled_camera.data.output["rgb"]
_assert_non_black_tensor(rgb)

_assert_non_black_tensor(rgb)
finally:
if env is not None:
env.close()
else:
SimulationContext.clear_instance()


@pytest.mark.isaacsim_ci
@pytest.mark.parametrize("backend_kind", ["physx", "newton"])
def test_newton_visualizer_non_black_viewer_frame(backend_kind: str):
"""Newton visualizer should produce at least one non-black viewer frame for Cartpole."""
env = None
try:
sim_utils.create_new_stage()
env = _make_cartpole_camera_env(visualizer_kind="newton", backend_kind=backend_kind)
env.sim._app_control_on_stop_handle = None # type: ignore[attr-defined]
env.reset()
actions = torch.zeros((env.num_envs, env.action_space.shape[-1]), device=env.device)
for _ in range(max(_SMOKE_STEPS, 6)):
env.step(action=actions)

newton_visualizers = [viz for viz in env.sim.visualizers if isinstance(viz, NewtonVisualizer)]
assert newton_visualizers, "Expected an initialized Newton visualizer."
viewer = getattr(newton_visualizers[0], "_viewer", None)
assert viewer is not None, "Newton viewer was not created."

get_frame = getattr(viewer, "get_frame", None)
if not callable(get_frame):
pytest.skip("ViewerGL.get_frame is not available in this Newton version.")
frame = get_frame()
_assert_non_black_frame_array(frame)
finally:
if env is not None:
env.close()
else:
SimulationContext.clear_instance()


if __name__ == "__main__":
pytest.main([__file__, "-v", "--maxfail=1"])
Loading