Skip to content

Commit ecf9b14

Browse files
committed
Close the Newton viewer before dropping the reference to it
NewtonVisualizer gave up its viewer in two places -- close() and the step() handler that permanently disables the viewer after an unrecoverable failure -- by assigning self._viewer = None without calling viewer.close() first. ViewerRTX.close() releases GPU resources in a fixed order: it waits on the in-flight render, drops the retained step results, unbinds the transform AttributeBinding and only then releases the ovrtx.Renderer. Skipping it leaves all four objects to the garbage collector, which guarantees no order. When the collector reaches the Renderer first it tears itself down with the binding and step results still live, warning "Renderer destroyed with 1 active binding(s)" and logging "Leaking step result outputs" once per retained render product. Route both paths through a single _release_viewer() helper that closes the viewer and clears the reference in a finally block, so an unusable viewer is never retained while the teardown failure still reaches the caller. The two callers need different error handling. close() lets the failure propagate -- SimulationContext._update_visualizers() already wraps it -- but runs its own remaining cleanup in a finally block so a failing viewer cannot strand the generated camera prims or leave _is_closed unset. The step() handler contains the failure, because that handler exists so an unusable viewer disables itself instead of aborting training; letting a teardown error escape would replace the original failure and crash the run it was written to save. ViewerBase.close() is a no-op, so the GL, null and file backends are unaffected. The viser and rerun visualizers already close their viewers. The _Viewer double in test_newton_adapter.py gains the close() method that every real viewer inherits from ViewerBase; without it the double no longer models the viewers it stands in for.
1 parent 7bf6959 commit ecf9b14

4 files changed

Lines changed: 276 additions & 8 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Fixed
2+
^^^^^
3+
4+
* Fixed :class:`~isaaclab_visualizers.newton.newton_visualizer.NewtonVisualizer` releasing its viewer
5+
without calling the viewer's :meth:`close`, which left the RTX backend's ordered GPU teardown to the
6+
garbage collector and intermittently leaked render step results and attribute bindings on shutdown.

source/isaaclab_visualizers/isaaclab_visualizers/newton/newton_visualizer.py

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,7 +1208,13 @@ def step(self, dt: float) -> None:
12081208
"[%s] Permanently disabling viewer after unrecoverable initialization failure.",
12091209
type(self).__name__,
12101210
)
1211-
self._viewer = None
1211+
try:
1212+
self._release_viewer()
1213+
except Exception:
1214+
# This handler exists so an unusable viewer disables itself
1215+
# instead of aborting training, so a viewer that also fails
1216+
# to close must not escape it either.
1217+
logger.exception("[%s] Viewer teardown failed.", type(self).__name__)
12121218

12131219
def is_reset_requested(self) -> bool:
12141220
"""Return whether an episode reset was requested via the viewer UI."""
@@ -1244,6 +1250,31 @@ def reset(self, soft: bool = False) -> None:
12441250
if self._picking_enabled:
12451251
self._viewer_picking_binding.bind(self._viewer)
12461252

1253+
def _release_viewer(self) -> None:
1254+
"""Close the viewer this visualizer owns and drop the reference to it.
1255+
1256+
The visualizer owns the viewer it creates, and the viewer owns GPU
1257+
resources that its backend releases in a fixed order:
1258+
``ViewerRTX.close()`` waits on the in-flight render, drops the retained
1259+
step results, unbinds the transform attribute binding and only then
1260+
releases the ``ovrtx.Renderer``. Dropping the reference without
1261+
closing leaves that ordering to the garbage collector, which does not
1262+
guarantee one; when the renderer is finalized before the resources
1263+
bound to it, it tears itself down with them still live and leaks them.
1264+
1265+
The reference is cleared in a ``finally`` block so an unusable viewer
1266+
is never retained, while the teardown failure itself still reaches the
1267+
caller. ``ViewerBase.close()`` is a no-op, so the non-RTX backends are
1268+
unaffected.
1269+
"""
1270+
viewer = self._viewer
1271+
if viewer is None:
1272+
return
1273+
try:
1274+
viewer.close()
1275+
finally:
1276+
self._viewer = None
1277+
12471278
def close(self) -> None:
12481279
"""Release viewer resources."""
12491280
if self._is_closed:
@@ -1252,13 +1283,17 @@ def close(self) -> None:
12521283
# Keep the stable callback registered: captured graphs replay its
12531284
# now-neutral device inputs without retaining the viewer.
12541285
self._viewer_picking_binding.deactivate()
1255-
if self._viewer is not None:
1256-
self._viewer = None
1257-
if self._camera_sensor is not None and self._camera_is_owned:
1258-
evict_visualizer_camera(self._streaming_camera_key)
1259-
remove_generated_prims(self._generated_camera_prim_paths)
1260-
self._camera_sensor = None
1261-
self._is_closed = True
1286+
try:
1287+
self._release_viewer()
1288+
finally:
1289+
# A viewer that fails to close must not strand the camera prims or
1290+
# leave the visualizer looking open; the failure still propagates
1291+
# to the caller, which logs it and drops the visualizer.
1292+
if self._camera_sensor is not None and self._camera_is_owned:
1293+
evict_visualizer_camera(self._streaming_camera_key)
1294+
remove_generated_prims(self._generated_camera_prim_paths)
1295+
self._camera_sensor = None
1296+
self._is_closed = True
12621297

12631298
def is_running(self) -> bool:
12641299
"""Return whether the visualizer should continue stepping."""

source/isaaclab_visualizers/test/test_newton_adapter.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,7 @@ def __init__(self):
487487
self.logged_state = None
488488
self.logged_contacts = None
489489
self.logged_arrows = None
490+
self.closed = False
490491

491492
def is_paused(self):
492493
return False
@@ -509,6 +510,10 @@ def log_arrows(self, name, starts, ends, colors):
509510
def end_frame(self):
510511
pass
511512

513+
def close(self):
514+
# Mirrors ViewerBase.close(), which every real viewer inherits.
515+
self.closed = True
516+
512517
def get_frame(self):
513518
return SimpleNamespace(numpy=lambda: np.zeros((4, 6, 3), dtype=np.uint8))
514519

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: BSD-3-Clause
5+
6+
"""Tests for :class:`NewtonVisualizer` viewer release.
7+
8+
The viewer owns GPU resources that its backend releases in a fixed order when
9+
``close()`` is called. Both paths that give up the viewer -- ``close()`` and
10+
the ``step()`` handler that permanently disables it after an unrecoverable
11+
failure -- must go through :meth:`NewtonVisualizer._release_viewer` so that
12+
ordering is honoured instead of being left to the garbage collector.
13+
14+
These tests assert the behaviour (the viewer's ``close()`` runs, and runs
15+
before the reference is dropped) rather than the absence of a backend log
16+
message: the message is emitted for only one of several valid finalization
17+
orders, so asserting on it would pass against unfixed code most of the time.
18+
19+
They also pin the error semantics, which differ by caller. ``_release_viewer``
20+
propagates a teardown failure while still clearing the reference. ``close()``
21+
lets it propagate to ``SimulationContext``, which already logs it, but finishes
22+
its own cleanup first. ``step()`` contains it, because that handler exists so
23+
an unusable viewer disables itself instead of aborting training.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import isaaclab_visualizers.newton.newton_visualizer as newton_visualizer
29+
import pytest
30+
from isaaclab_visualizers.newton.newton_visualizer import NewtonVisualizer
31+
32+
pytestmark = [pytest.mark.unit]
33+
34+
35+
class _SpyViewer:
36+
"""Viewer double that records how and when it was closed."""
37+
38+
def __init__(self, raises: bool = False) -> None:
39+
self.close_calls = 0
40+
self.referenced_by_owner_at_close: list[bool] = []
41+
self.owner: NewtonVisualizer | None = None
42+
self._raises = raises
43+
44+
def close(self) -> None:
45+
self.close_calls += 1
46+
# Record whether the visualizer still pointed at us while we were being
47+
# closed. The reference must outlive the teardown call.
48+
self.referenced_by_owner_at_close.append(getattr(self.owner, "_viewer", None) is self)
49+
if self._raises:
50+
raise RuntimeError("Failed to create window")
51+
52+
53+
def _make_visualizer(viewer: _SpyViewer | None) -> NewtonVisualizer:
54+
"""Build the minimal visualizer state that the release paths read.
55+
56+
``__init__`` is bypassed deliberately: a real visualizer requires a Newton
57+
model, a scene data provider and a GPU, none of which this behaviour
58+
depends on.
59+
"""
60+
visualizer = object.__new__(NewtonVisualizer)
61+
visualizer._is_closed = False
62+
visualizer._picking_enabled = False
63+
visualizer._viewer = viewer
64+
visualizer._camera_sensor = None
65+
visualizer._camera_is_owned = False
66+
if viewer is not None:
67+
viewer.owner = visualizer
68+
return visualizer
69+
70+
71+
def test_release_viewer_closes_before_clearing_reference() -> None:
72+
"""The viewer must be closed while the visualizer still references it."""
73+
viewer = _SpyViewer()
74+
visualizer = _make_visualizer(viewer)
75+
76+
visualizer._release_viewer()
77+
78+
assert viewer.close_calls == 1
79+
assert viewer.referenced_by_owner_at_close == [True]
80+
assert visualizer._viewer is None
81+
82+
83+
def test_release_viewer_propagates_failure_and_still_clears_reference() -> None:
84+
"""A teardown failure must reach the caller, but must not retain the viewer."""
85+
viewer = _SpyViewer(raises=True)
86+
visualizer = _make_visualizer(viewer)
87+
88+
with pytest.raises(RuntimeError, match="Failed to create window"):
89+
visualizer._release_viewer()
90+
91+
assert viewer.close_calls == 1
92+
assert visualizer._viewer is None
93+
94+
95+
def test_release_viewer_without_viewer_is_a_no_op() -> None:
96+
"""Releasing when no viewer is held must be harmless."""
97+
visualizer = _make_visualizer(None)
98+
99+
visualizer._release_viewer()
100+
101+
assert visualizer._viewer is None
102+
103+
104+
def test_release_viewer_is_idempotent() -> None:
105+
"""Releasing twice must not close the viewer twice."""
106+
viewer = _SpyViewer()
107+
visualizer = _make_visualizer(viewer)
108+
109+
visualizer._release_viewer()
110+
visualizer._release_viewer()
111+
112+
assert viewer.close_calls == 1
113+
114+
115+
def test_close_releases_the_viewer() -> None:
116+
"""``close()`` must release the viewer through the shared path."""
117+
viewer = _SpyViewer()
118+
visualizer = _make_visualizer(viewer)
119+
120+
visualizer.close()
121+
122+
assert viewer.close_calls == 1
123+
assert viewer.referenced_by_owner_at_close == [True]
124+
assert visualizer._viewer is None
125+
assert visualizer._is_closed is True
126+
127+
128+
def test_close_is_idempotent() -> None:
129+
"""A second ``close()`` must not close the viewer again."""
130+
viewer = _SpyViewer()
131+
visualizer = _make_visualizer(viewer)
132+
133+
visualizer.close()
134+
visualizer.close()
135+
136+
assert viewer.close_calls == 1
137+
138+
139+
def test_close_completes_cleanup_when_viewer_teardown_fails(monkeypatch: pytest.MonkeyPatch) -> None:
140+
"""A failing viewer must not strand the owned camera or the closed flag.
141+
142+
``SimulationContext`` already logs an exception raised by ``close()``, so
143+
it is allowed to propagate -- but the rest of the teardown still has to
144+
run, otherwise a viewer failure silently leaks the generated camera prims.
145+
"""
146+
evicted: list[object] = []
147+
removed: list[object] = []
148+
monkeypatch.setattr(newton_visualizer, "evict_visualizer_camera", evicted.append, raising=False)
149+
monkeypatch.setattr(newton_visualizer, "remove_generated_prims", removed.append, raising=False)
150+
151+
viewer = _SpyViewer(raises=True)
152+
visualizer = _make_visualizer(viewer)
153+
visualizer._camera_sensor = object()
154+
visualizer._camera_is_owned = True
155+
visualizer._streaming_camera_key = "camera-key"
156+
visualizer._generated_camera_prim_paths = ["/World/generated"]
157+
158+
with pytest.raises(RuntimeError, match="Failed to create window"):
159+
visualizer.close()
160+
161+
assert visualizer._viewer is None
162+
assert visualizer._camera_sensor is None
163+
assert visualizer._is_closed is True
164+
assert evicted == ["camera-key"]
165+
assert removed == [["/World/generated"]]
166+
167+
168+
def _arm_for_step_failure(visualizer: NewtonVisualizer, viewer: _SpyViewer) -> None:
169+
"""Drive ``step()`` far enough to reach its viewer-failure handler."""
170+
visualizer._is_initialized = True
171+
visualizer._runtime_headless = False
172+
visualizer._disable_viewer_on_step_exception = True
173+
visualizer._sim_time = 0.0
174+
visualizer._step_counter = 0
175+
visualizer._state = None
176+
visualizer._scene_data_provider = None
177+
visualizer._update_frequency = 1
178+
viewer._update_frequency = 1
179+
180+
def _unrecoverable() -> bool:
181+
raise RuntimeError("Failed to create window")
182+
183+
viewer.is_paused = _unrecoverable # type: ignore[method-assign]
184+
185+
186+
def test_step_failure_releases_the_viewer(monkeypatch: pytest.MonkeyPatch) -> None:
187+
"""An unrecoverable viewer failure during ``step()`` must release the viewer.
188+
189+
``NewtonRTXVisualizer`` sets ``_disable_viewer_on_step_exception`` so the
190+
viewer is given up after the first failure -- for example when OVRTX cannot
191+
create its window. That path must close the viewer rather than only
192+
dropping the reference to it.
193+
"""
194+
viewer = _SpyViewer()
195+
visualizer = _make_visualizer(viewer)
196+
_arm_for_step_failure(visualizer, viewer)
197+
monkeypatch.setattr(newton_visualizer.NewtonManager, "get_num_envs", staticmethod(lambda: 1), raising=False)
198+
199+
NewtonVisualizer.step(visualizer, dt=0.01) # must not raise
200+
201+
assert viewer.close_calls == 1
202+
assert viewer.referenced_by_owner_at_close == [True]
203+
assert visualizer._viewer is None
204+
205+
206+
def test_step_contains_a_failing_viewer_teardown(monkeypatch: pytest.MonkeyPatch) -> None:
207+
"""A viewer that fails to close must not abort training from ``step()``.
208+
209+
This is the whole purpose of the ``_disable_viewer_on_step_exception``
210+
handler: the viewer is already known to be broken, so its teardown failure
211+
has to be contained rather than replacing the original failure and
212+
propagating out of the simulation loop.
213+
"""
214+
viewer = _SpyViewer(raises=True)
215+
visualizer = _make_visualizer(viewer)
216+
_arm_for_step_failure(visualizer, viewer)
217+
monkeypatch.setattr(newton_visualizer.NewtonManager, "get_num_envs", staticmethod(lambda: 1), raising=False)
218+
219+
NewtonVisualizer.step(visualizer, dt=0.01) # must not raise
220+
221+
assert viewer.close_calls == 1
222+
assert visualizer._viewer is None

0 commit comments

Comments
 (0)