Skip to content

Commit 8b63997

Browse files
authored
feat: add typed service locator to SimulationContext (#5672)
## Motivation `SimulationContext` is the natural lifecycle owner for backend-specific caches (e.g. UsdRT stage handles, Fabric hierarchy data). Currently these either live as class-level globals (no lifecycle, leak across stages) or get baked directly into SimulationContext (pollutes it with backend imports). ## Solution Add a lightweight typed `ServiceLocator` exposed via `SimulationContext.services`. Backends register their own singletons using subscript syntax: ```python sim.services[FabricStageCache] = FabricStageCache(stage) cache = sim.services[FabricStageCache] del sim.services[FabricStageCache] # closes and removes ``` All registered services are closed when `clear_instance()` is called. Exceptions during close are collected and raised after full teardown completes. ## Design - Keyed by service class (typed retrieval) - Services with a `close()` method are automatically closed on deletion or teardown - `close_all(caught_exceptions)` always collects — no silent failures - Purely additive — no existing behavior changes ## Downstream This is used by the Fabric stage cache PR (#5676) to manage `IFabricHierarchy` handles per stage.
1 parent a34c5f0 commit 8b63997

4 files changed

Lines changed: 304 additions & 0 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
Added
2+
^^^^^
3+
4+
* Added :class:`~isaaclab.sim.ServiceLocator` and exposed it as
5+
:attr:`~isaaclab.sim.SimulationContext.services`.
6+
7+
Backend-specific caches can be registered and retrieved using subscript
8+
syntax (``services[cls] = instance``, ``services[cls]``). Services with
9+
a ``close()`` method are automatically closed on ``clear_instance()``.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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+
"""Typed service locator for lifecycle-managed singletons."""
7+
8+
from __future__ import annotations
9+
10+
from typing import TypeVar
11+
12+
_T = TypeVar("_T")
13+
14+
15+
def _try_close(service: object) -> None:
16+
"""Call close() on *service* if it exists and is callable."""
17+
close = getattr(service, "close", None)
18+
if callable(close):
19+
close()
20+
21+
22+
class ServiceLocator:
23+
"""A typed service registry keyed by class, interface, or abstract base class.
24+
25+
Services are registered and retrieved using subscript syntax::
26+
27+
locator[FabricStageCache] = FabricStageCache(stage)
28+
cache = locator[FabricStageCache]
29+
30+
Deleting a service calls ``close()`` on it if available::
31+
32+
del locator[FabricStageCache]
33+
34+
All registered services are closed and cleared via :meth:`close_all`.
35+
"""
36+
37+
def __init__(self) -> None:
38+
self._services: dict[type, object] = {}
39+
40+
def __getitem__(self, cls: type[_T]) -> _T | None:
41+
"""Retrieve a service by its key class, or ``None`` if not registered."""
42+
return self._services.get(cls) # type: ignore[return-value]
43+
44+
def __setitem__(self, cls: type[_T], instance: _T) -> None:
45+
"""Register a service under the given key.
46+
47+
The key can be the concrete class of *instance*, a parent class,
48+
or an abstract base class / protocol — allowing retrieval by
49+
interface rather than implementation.
50+
51+
Does *not* close a previously registered service — the caller is
52+
responsible for closing the old instance before replacing it.
53+
Use ``del locator[cls]`` to close and remove, or :meth:`pop` to
54+
remove without closing.
55+
"""
56+
self._services[cls] = instance
57+
58+
def __delitem__(self, cls: type) -> None:
59+
"""Close and remove a service.
60+
61+
Calls ``close()`` on the instance if it has one, then removes it.
62+
63+
Raises:
64+
KeyError: If no service is registered under *cls*.
65+
"""
66+
instance = self._services.pop(cls)
67+
_try_close(instance)
68+
69+
def __contains__(self, cls: type) -> bool:
70+
"""Check if a service is registered under *cls*."""
71+
return cls in self._services
72+
73+
def pop(self, cls: type[_T]) -> _T | None:
74+
"""Remove and return a service without closing it.
75+
76+
Returns:
77+
The previously registered instance, or ``None`` if not registered.
78+
"""
79+
return self._services.pop(cls, None) # type: ignore[return-value]
80+
81+
def close_all(self, caught_exceptions: list[Exception]) -> None:
82+
"""Close all registered services and clear the registry.
83+
84+
Calls ``close()`` on each service that has one. Exceptions are
85+
always collected into *caught_exceptions* — closing continues for
86+
all remaining services regardless of failures.
87+
88+
Args:
89+
caught_exceptions: A list to which any exceptions raised by
90+
service ``close()`` calls are appended.
91+
"""
92+
services = list(self._services.values())
93+
self._services.clear()
94+
for service in services:
95+
try:
96+
_try_close(service)
97+
except Exception as e:
98+
caught_exceptions.append(e)

source/isaaclab/isaaclab/sim/simulation_context.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
)
3131
from isaaclab.renderers.render_context import RenderContext
3232
from isaaclab.scene_data import SceneDataProvider
33+
from isaaclab.sim.service_locator import ServiceLocator
3334
from isaaclab.sim.utils import create_new_stage
3435
from isaaclab.utils.string import clear_resolve_matching_names_cache
3536
from isaaclab.utils.version import has_kit
@@ -214,6 +215,8 @@ def __init__(self, cfg: SimulationCfg | None = None):
214215
order=5,
215216
)
216217

218+
self._services = ServiceLocator()
219+
217220
type(self)._instance = self # Mark as valid singleton only after successful init
218221

219222
def _apply_render_cfg_settings(self) -> None:
@@ -850,6 +853,22 @@ def get_setting(self, name: str) -> Any:
850853
"""Get a setting value."""
851854
return self._settings_helper.get(name)
852855

856+
# ------------------------------------------------------------------
857+
# Service locator
858+
# ------------------------------------------------------------------
859+
860+
@property
861+
def services(self) -> ServiceLocator:
862+
"""Typed service registry for backend-specific singletons.
863+
864+
Usage::
865+
866+
sim_context.services[FabricStageCache] = cache
867+
cache = sim_context.services[FabricStageCache]
868+
del sim_context.services[FabricStageCache] # closes and removes
869+
"""
870+
return self._services
871+
853872
@classmethod
854873
def clear_instance(cls) -> None:
855874
"""Clean up resources and clear the singleton instance."""
@@ -863,6 +882,10 @@ def clear_instance(cls) -> None:
863882
viz.close()
864883
cls._instance._visualizers.clear()
865884

885+
# Close and drop all registered singleton services
886+
service_errors: list[Exception] = []
887+
cls._instance._services.close_all(caught_exceptions=service_errors)
888+
866889
# Tear down the stage. We skip clear_stage() (prim-by-prim deletion) since
867890
# close_stage() + app shutdown destroy the entire stage at once.
868891
stage_utils.close_stage()
@@ -876,6 +899,11 @@ def clear_instance(cls) -> None:
876899
gc.collect()
877900
logger.info("SimulationContext cleared")
878901

902+
if service_errors:
903+
msg = f"SimulationContext.clear_instance(): {len(service_errors)} service(s) failed to close"
904+
# TODO: Use ExceptionGroup when ruff target-version is bumped to py311+
905+
raise RuntimeError(msg) from service_errors[0]
906+
879907
@classmethod
880908
def clear_stage(cls) -> None:
881909
"""Clear the current USD stage (preserving /World and PhysicsScene).
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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 ServiceLocator."""
7+
8+
import pytest
9+
10+
from isaaclab.sim.service_locator import ServiceLocator
11+
12+
# -- Dummy service helpers --
13+
14+
15+
class _DummyServiceWithClose:
16+
"""Service with a callable close() method."""
17+
18+
def __init__(self):
19+
self.closed = False
20+
21+
def close(self):
22+
self.closed = True
23+
24+
25+
class _DummyServiceWithoutClose:
26+
"""Service without a close() method."""
27+
28+
pass
29+
30+
31+
class _DummyServiceWithCloseProperty:
32+
"""Service where 'close' exists but is not callable."""
33+
34+
close = 42 # attribute, not a method
35+
36+
37+
class _DummyServiceThatThrows:
38+
"""Service whose close() raises an exception."""
39+
40+
def close(self):
41+
raise RuntimeError("close failed")
42+
43+
44+
# -- Fixtures --
45+
46+
47+
@pytest.fixture
48+
def locator():
49+
"""Provide a fresh ServiceLocator for each test."""
50+
return ServiceLocator()
51+
52+
53+
# -- Tests --
54+
55+
56+
def test_get_returns_none_when_unregistered(locator):
57+
assert locator[_DummyServiceWithClose] is None
58+
59+
60+
def test_set_and_get(locator):
61+
svc = _DummyServiceWithClose()
62+
locator[_DummyServiceWithClose] = svc
63+
assert locator[_DummyServiceWithClose] is svc
64+
65+
66+
def test_contains(locator):
67+
assert _DummyServiceWithClose not in locator
68+
locator[_DummyServiceWithClose] = _DummyServiceWithClose()
69+
assert _DummyServiceWithClose in locator
70+
71+
72+
def test_del_closes_service(locator):
73+
svc = _DummyServiceWithClose()
74+
locator[_DummyServiceWithClose] = svc
75+
del locator[_DummyServiceWithClose]
76+
assert svc.closed
77+
assert locator[_DummyServiceWithClose] is None
78+
79+
80+
def test_del_without_close_method(locator):
81+
"""del on a service without close() should not raise."""
82+
locator[_DummyServiceWithoutClose] = _DummyServiceWithoutClose()
83+
del locator[_DummyServiceWithoutClose]
84+
assert locator[_DummyServiceWithoutClose] is None
85+
86+
87+
def test_del_with_non_callable_close_property(locator):
88+
"""del on a service where close is a property (not callable) should not raise."""
89+
svc = _DummyServiceWithCloseProperty()
90+
locator[_DummyServiceWithCloseProperty] = svc
91+
del locator[_DummyServiceWithCloseProperty]
92+
assert locator[_DummyServiceWithCloseProperty] is None
93+
94+
95+
def test_del_missing_raises_key_error(locator):
96+
with pytest.raises(KeyError):
97+
del locator[_DummyServiceWithClose]
98+
99+
100+
def test_pop_returns_without_closing(locator):
101+
svc = _DummyServiceWithClose()
102+
locator[_DummyServiceWithClose] = svc
103+
popped = locator.pop(_DummyServiceWithClose)
104+
assert popped is svc
105+
assert not svc.closed
106+
assert locator[_DummyServiceWithClose] is None
107+
108+
109+
def test_pop_missing_returns_none(locator):
110+
assert locator.pop(_DummyServiceWithClose) is None
111+
112+
113+
def test_close_all(locator):
114+
svc1 = _DummyServiceWithClose()
115+
svc2 = _DummyServiceWithoutClose()
116+
locator[_DummyServiceWithClose] = svc1
117+
locator[_DummyServiceWithoutClose] = svc2
118+
errors: list[Exception] = []
119+
locator.close_all(caught_exceptions=errors)
120+
assert svc1.closed
121+
assert not errors
122+
assert locator[_DummyServiceWithClose] is None
123+
assert locator[_DummyServiceWithoutClose] is None
124+
125+
126+
def test_close_all_skips_non_callable_close(locator):
127+
"""close_all does not crash on services with non-callable close attribute."""
128+
locator[_DummyServiceWithCloseProperty] = _DummyServiceWithCloseProperty()
129+
errors: list[Exception] = []
130+
locator.close_all(caught_exceptions=errors)
131+
assert not errors
132+
assert locator[_DummyServiceWithCloseProperty] is None
133+
134+
135+
def test_close_all_collects_exceptions(locator):
136+
"""Exceptions are collected and all services still get closed."""
137+
svc_ok = _DummyServiceWithClose()
138+
locator[_DummyServiceWithClose] = svc_ok
139+
locator[_DummyServiceThatThrows] = _DummyServiceThatThrows()
140+
errors: list[Exception] = []
141+
locator.close_all(caught_exceptions=errors)
142+
assert svc_ok.closed
143+
assert len(errors) == 1
144+
assert isinstance(errors[0], RuntimeError)
145+
assert locator[_DummyServiceWithClose] is None
146+
assert locator[_DummyServiceThatThrows] is None
147+
148+
149+
def test_multiple_service_types(locator):
150+
svc1 = _DummyServiceWithClose()
151+
svc2 = _DummyServiceWithoutClose()
152+
locator[_DummyServiceWithClose] = svc1
153+
locator[_DummyServiceWithoutClose] = svc2
154+
assert locator[_DummyServiceWithClose] is svc1
155+
assert locator[_DummyServiceWithoutClose] is svc2
156+
157+
158+
def test_base_class_key(locator):
159+
"""Can register under a base class and retrieve by it."""
160+
161+
class Base:
162+
pass
163+
164+
class Impl(Base):
165+
pass
166+
167+
impl = Impl()
168+
locator[Base] = impl
169+
assert locator[Base] is impl

0 commit comments

Comments
 (0)