diff --git a/source/isaaclab/changelog.d/remove-ovphysx-bootstrap-hacks.skip b/source/isaaclab/changelog.d/remove-ovphysx-bootstrap-hacks.skip new file mode 100644 index 000000000000..9909d19621da --- /dev/null +++ b/source/isaaclab/changelog.d/remove-ovphysx-bootstrap-hacks.skip @@ -0,0 +1 @@ +Test-only kitless interface cleanup for ovphysx bootstrap changes. diff --git a/source/isaaclab/test/assets/test_articulation_iface.py b/source/isaaclab/test/assets/test_articulation_iface.py index 8dbf19291c80..b3322a62887b 100644 --- a/source/isaaclab/test/assets/test_articulation_iface.py +++ b/source/isaaclab/test/assets/test_articulation_iface.py @@ -19,14 +19,9 @@ import sys from unittest.mock import MagicMock -# When running kitless (e.g., ovphysx backend via run_ovphysx.sh), AppLauncher -# will try to boot Kit and hang. Skip it entirely: run_ovphysx.sh sets -# LD_PRELOAD to the ovphysx libcarb.so, which is the signature of a kitless -# ovphysx run. Also guard the case where neither LD_PRELOAD nor EXP_PATH is -# set (bare Python, no Kit at all). -_kitless = "ovphysx" in os.environ.get("LD_PRELOAD", "") or ( - os.environ.get("LD_PRELOAD", "") == "" and "EXP_PATH" not in os.environ -) +# When running kitless, AppLauncher will try to boot Kit and hang. Skip it +# entirely when no Kit experience path is available. +_kitless = "EXP_PATH" not in os.environ if not _kitless: from isaaclab.app import AppLauncher @@ -34,8 +29,7 @@ simulation_app = AppLauncher(headless=True).app else: simulation_app = None - # Stub out the Kit/Omniverse modules that are not present under - # run_ovphysx.sh (pxr, carb, omni, omni.kit[.app] are real on PYTHONPATH). + # Stub out the Omniverse modules that are not present under kitless test runs. # ``omni`` is a real namespace package, so missing submodules also need # to be installed as attributes on it -- ``sys.modules`` alone is not # enough because attribute access on the real ``omni`` won't fall @@ -65,10 +59,6 @@ _mock_physics_sim_view = MagicMock() _mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) -from isaaclab_physx.physics import PhysxManager as SimulationManager - -SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) - """ Check which backends are available. """ @@ -78,8 +68,10 @@ try: from isaaclab_physx.assets.articulation.articulation import Articulation as PhysXArticulation from isaaclab_physx.assets.articulation.articulation_data import ArticulationData as PhysXArticulationData + from isaaclab_physx.physics import PhysxManager as PhysXSimulationManager from isaaclab_physx.test.mock_interfaces.views import MockArticulationViewWarp as PhysXMockArticulationViewWarp + PhysXSimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) BACKENDS.append("physx") except ImportError: pass @@ -152,7 +144,7 @@ def create_physx_articulation( # We can't call the initialize method here, because we don't have a good mock for the actuators yet. # We need to set the _data attribute manually. - # Create ArticulationData instance (SimulationManager already mocked at module level) + # Create ArticulationData instance (PhysX manager patched when the backend import succeeds). data = PhysXArticulationData(mock_view, device) object.__setattr__(articulation, "_data", data) diff --git a/source/isaaclab/test/assets/test_rigid_object_collection_iface.py b/source/isaaclab/test/assets/test_rigid_object_collection_iface.py index 410b0aabcc0c..969b8d8fb83d 100644 --- a/source/isaaclab/test/assets/test_rigid_object_collection_iface.py +++ b/source/isaaclab/test/assets/test_rigid_object_collection_iface.py @@ -20,14 +20,9 @@ import sys from unittest.mock import MagicMock -# When running kitless (e.g., ovphysx backend via run_ovphysx.sh), AppLauncher -# will try to boot Kit and hang. Skip it entirely: run_ovphysx.sh sets -# LD_PRELOAD to the ovphysx libcarb.so, which is the signature of a kitless -# ovphysx run. Also guard the case where neither LD_PRELOAD nor EXP_PATH is -# set (bare Python, no Kit at all). -_kitless = "ovphysx" in os.environ.get("LD_PRELOAD", "") or ( - os.environ.get("LD_PRELOAD", "") == "" and "EXP_PATH" not in os.environ -) +# When running kitless, AppLauncher will try to boot Kit and hang. Skip it +# entirely when no Kit experience path is available. +_kitless = "EXP_PATH" not in os.environ if not _kitless: from isaaclab.app import AppLauncher @@ -35,8 +30,7 @@ simulation_app = AppLauncher(headless=True).app else: simulation_app = None - # Stub out the Kit/Omniverse modules that are not present under - # run_ovphysx.sh (pxr, carb, omni, omni.kit[.app] are real on PYTHONPATH). + # Stub out the Omniverse modules that are not present under kitless test runs. # ``omni`` is a real namespace package, so missing submodules also need # to be installed as attributes on it -- ``sys.modules`` alone is not # enough because attribute access on the real ``omni`` won't fall @@ -64,10 +58,6 @@ _mock_physics_sim_view = MagicMock() _mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) -from isaaclab_physx.physics import PhysxManager as SimulationManager - -SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) - """ Check which backends are available. """ @@ -81,8 +71,10 @@ from isaaclab_physx.assets.rigid_object_collection.rigid_object_collection_data import ( RigidObjectCollectionData as PhysXRigidObjectCollectionData, ) + from isaaclab_physx.physics import PhysxManager as PhysXSimulationManager from isaaclab_physx.test.mock_interfaces.views import MockRigidBodyViewWarp as PhysXMockRigidBodyViewWarp + PhysXSimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) BACKENDS.append("physx") except ImportError: pass diff --git a/source/isaaclab/test/assets/test_rigid_object_iface.py b/source/isaaclab/test/assets/test_rigid_object_iface.py index 772130149ee3..09edcb895251 100644 --- a/source/isaaclab/test/assets/test_rigid_object_iface.py +++ b/source/isaaclab/test/assets/test_rigid_object_iface.py @@ -19,14 +19,9 @@ import sys from unittest.mock import MagicMock -# When running kitless (e.g., ovphysx backend via run_ovphysx.sh), AppLauncher -# will try to boot Kit and hang. Skip it entirely: run_ovphysx.sh sets -# LD_PRELOAD to the ovphysx libcarb.so, which is the signature of a kitless -# ovphysx run. Also guard the case where neither LD_PRELOAD nor EXP_PATH is -# set (bare Python, no Kit at all). -_kitless = "ovphysx" in os.environ.get("LD_PRELOAD", "") or ( - os.environ.get("LD_PRELOAD", "") == "" and "EXP_PATH" not in os.environ -) +# When running kitless, AppLauncher will try to boot Kit and hang. Skip it +# entirely when no Kit experience path is available. +_kitless = "EXP_PATH" not in os.environ if not _kitless: from isaaclab.app import AppLauncher @@ -34,8 +29,7 @@ simulation_app = AppLauncher(headless=True).app else: simulation_app = None - # Stub out the Kit/Omniverse modules that are not present under - # run_ovphysx.sh (pxr, carb, omni, omni.kit[.app] are real on PYTHONPATH). + # Stub out the Omniverse modules that are not present under kitless test runs. # ``omni`` is a real namespace package, so missing submodules also need # to be installed as attributes on it -- ``sys.modules`` alone is not # enough because attribute access on the real ``omni`` won't fall @@ -64,10 +58,6 @@ _mock_physics_sim_view = MagicMock() _mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) -from isaaclab_physx.physics import PhysxManager as SimulationManager - -SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) - """ Check which backends are available. """ @@ -77,8 +67,10 @@ try: from isaaclab_physx.assets.rigid_object.rigid_object import RigidObject as PhysXRigidObject from isaaclab_physx.assets.rigid_object.rigid_object_data import RigidObjectData as PhysXRigidObjectData + from isaaclab_physx.physics import PhysxManager as PhysXSimulationManager from isaaclab_physx.test.mock_interfaces.views import MockRigidBodyViewWarp as PhysXMockRigidBodyViewWarp + PhysXSimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) BACKENDS.append("physx") except ImportError: pass @@ -124,7 +116,7 @@ def create_physx_rigid_object( object.__setattr__(rigid_object, "_root_view", mock_view) object.__setattr__(rigid_object, "_device", device) - # Create RigidObjectData instance (SimulationManager already mocked at module level) + # Create RigidObjectData instance (PhysX manager patched when the backend import succeeds). data = PhysXRigidObjectData(mock_view, device) object.__setattr__(rigid_object, "_data", data) diff --git a/source/isaaclab_ovphysx/changelog.d/remove-ovphysx-bootstrap-hacks.minor.rst b/source/isaaclab_ovphysx/changelog.d/remove-ovphysx-bootstrap-hacks.minor.rst new file mode 100644 index 000000000000..5a130ad4b236 --- /dev/null +++ b/source/isaaclab_ovphysx/changelog.d/remove-ovphysx-bootstrap-hacks.minor.rst @@ -0,0 +1,13 @@ +Changed +^^^^^^^ + +* Changed :mod:`isaaclab_ovphysx` to require the ovphysx 0.5 runtime line and + use its namespaced USD and static-Carbonite lifecycle directly. Users should + install ``ovphysx>=0.5,<0.6`` instead of ``ovphysx==0.4.13``. + +Removed +^^^^^^^ + +* Removed the OVPhysX manager's temporary host ``pxr`` hiding, forced + process-exit shutdown hook, and wrapper-specific kitless test assumptions. + Run OVPhysX tests through ``./isaaclab.sh -p -m pytest``. diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py index 160f6a12abc8..901b5eb82723 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/physics/ovphysx_manager.py @@ -12,8 +12,6 @@ from __future__ import annotations -import atexit -import inspect import logging import os import re @@ -207,18 +205,17 @@ class OvPhysxManager(PhysicsManager): _stage_path: ClassVar[str | None] = None _warmup_done: ClassVar[bool] = False _tmp_dir: ClassVar[tempfile.TemporaryDirectory | None] = None - # Device the process is locked to once :meth:`_warmup_and_load` constructs the - # ``ovphysx.PhysX`` instance for the first time. ``ovphysx<=0.3.7`` enforces - # a process-global device-mode lock at the C++ layer (see HACK note on - # :meth:`_release_physx`); we mirror it here so a clear Python error is raised - # if a later :class:`~isaaclab.sim.SimulationContext` requests a different device. + # Device the process is locked to once :meth:`_warmup_and_load` constructs + # the ``ovphysx.PhysX`` instance for the first time. The ovphysx runtime + # keeps device mode process-global; mirror it here so a clear Python error + # is raised if a later :class:`~isaaclab.sim.SimulationContext` requests a + # different device. _locked_device: ClassVar[str | None] = None # Pending (source, targets, parent_positions) triples queued by # queue_ovphysx_replication() before the PhysX instance exists. Replayed via # physx.clone() in _warmup_and_load(). # parent_positions is a list of (x, y, z) tuples — one per target. _pending_clones: ClassVar[list[tuple[str, list[str], list[tuple[float, float, float]]]]] = [] - _atexit_registered: ClassVar[bool] = False _scene_data_backend: ClassVar[OvPhysxSceneDataBackend | None] = None @classmethod @@ -286,13 +283,10 @@ def initialize(cls, sim_context: SimulationContext) -> None: the stage may not be fully populated at this point. The actual load happens lazily in :meth:`reset`. - ``cls._physx`` is intentionally not cleared here: the ovphysx C++ instance - is process-global (see HACK on :meth:`_release_physx`). When a previous - :class:`SimulationContext` has already constructed it, we reuse it rather - than dropping the only Python reference (which would trigger the - destructor race) or re-constructing (which would hit the wheel's - device-mode lock). ``cls._locked_device`` carries the device the cached - instance is bound to. + ``cls._physx`` is intentionally not cleared here: if a previous + :class:`SimulationContext` has already constructed it and has not been + closed, the manager reuses that instance. ``cls._locked_device`` carries + the process-global device mode the ovphysx runtime is bound to. """ super().initialize(sim_context) cls._ensure_physx_schemas_registered() @@ -350,7 +344,7 @@ def close(cls) -> None: cls._stage_path = None cls._warmup_done = False # Drop the SceneDataBackend singleton: its cached ``TensorBinding`` handles - # point into the wheel's prior scene which we just ``physx.reset()``-ed. + # point into the wheel instance we just released. # The next :class:`SimulationContext` re-creates the backend in # :meth:`initialize`. Matches Newton's lifecycle. cls._scene_data_backend = None @@ -363,27 +357,15 @@ def close(cls) -> None: @classmethod def _release_physx(cls) -> None: - """Soft-reset the ovphysx runtime stage; keep the C++ instance alive. - - Calls ``physx.reset()`` to clear the loaded scene, but does **not** drop - the Python reference. The cached :class:`ovphysx.PhysX` is reused by the - next :class:`~isaaclab.sim.SimulationContext` via the reuse path in - :meth:`_warmup_and_load`. Safe to call multiple times. - - HACK(ovphysx<=0.3.7): the wheel's bundled libcarb.so and Kit's libcarb.so - coexist in the same process whenever ``import pxr`` runs (Kit USD plugins - on ``LD_LIBRARY_PATH`` pull in Kit's Carbonite). Both register C++ static - destructors that race at process exit -- and crucially, also race when - ``ovphysx.PhysX``'s Python destructor fires mid-process via refcount drop. - So we must never let the only Python reference go to zero while the - process is alive. ``os._exit(0)`` (registered via ``atexit`` in - :meth:`_warmup_and_load`) sidesteps the static-destructor phase entirely - at process exit. Remove this workaround once the wheel ships a - namespace-isolated Carbonite (different soname / hidden visibility). + """Release the ovphysx runtime instance. + + Safe to call multiple times. Device mode remains process-global in the + ovphysx runtime, so ``_locked_device`` intentionally survives release. """ if cls._physx is not None: - op = cls._physx.reset() - cls._physx.wait_op(op) + physx = cls._physx + cls._physx = None + physx.release() @classmethod def get_physx_instance(cls) -> Any: @@ -491,11 +473,11 @@ def _warmup_and_load(cls) -> None: """Export the USD stage and load it into the ovphysx runtime. On the first call per process, constructs the :class:`ovphysx.PhysX` - instance, registers the ``atexit`` handler, and locks the process to - the resolved device. On subsequent calls, reuses the cached instance - (see HACK on :meth:`_release_physx`) -- exporting the new USD, - re-attaching it via ``add_usd``, replaying pending clones, and (on GPU) - re-running ``warmup_gpu`` so the new stage's bodies are resident. + instance and locks the process to the resolved device. On subsequent + calls before :meth:`close`, reuses the cached instance -- exporting the + new USD, re-attaching it via ``add_usd``, replaying pending clones, and + (on GPU) re-running ``warmup_gpu`` so the new stage's bodies are + resident. Raises: RuntimeError: if ``SimulationContext`` is not set, or if a device @@ -520,8 +502,8 @@ def _warmup_and_load(cls) -> None: if cls._locked_device is not None and ovphysx_device != cls._locked_device: raise RuntimeError( f"OvPhysxManager is locked to device {cls._locked_device!r} for the lifetime of this process; " - f"cannot switch to {ovphysx_device!r}. ovphysx<=0.3.7 binds device mode at the C++ layer on the " - "first ovphysx.PhysX(...) construction and it cannot be changed without restarting the process." + f"cannot switch to {ovphysx_device!r}. ovphysx binds device mode at the C++ layer on the first " + "ovphysx.PhysX(...) construction and it cannot be changed without restarting the process." ) scene_prim = sim.stage.GetPrimAtPath(sim.cfg.physics_prim_path) @@ -613,95 +595,34 @@ def _warmup_and_load(cls) -> None: def _construct_physx(cls, ovphysx_device: str, gpu_index: int) -> None: """Bootstrap the ``ovphysx`` wheel and create the :class:`ovphysx.PhysX` instance. - Runs once per process. Configures worker threads, registers the - process-exit ``os._exit(0)`` handler, and stores the result on - ``cls._physx``. See HACK on :meth:`_release_physx` for why the - instance must outlive every individual :class:`SimulationContext`. + Configures worker threads and stores the result on ``cls._physx``. """ - # HACK (temporary): hide pxr from sys.modules during ovphysx bootstrap. - # IsaacSim's pxr reports version 0.25.5 (pip convention) while ovphysx - # expects 25.11 (OpenUSD release convention). Hiding pxr causes - # ovphysx.check_usd_compatibility() to skip the Python-side version - # check. This should go away once ovphysx ships a namespaced USD - # copy with isolated symbols (same "import pxr" API, no collision). - import sys as _sys - - _hidden_pxr = {k: _sys.modules.pop(k) for k in list(_sys.modules) if k == "pxr" or k.startswith("pxr.")} - try: - _ovphysx_bootstrap = import_ovphysx() - _ovphysx_bootstrap.bootstrap() - finally: - _sys.modules.update(_hidden_pxr) - ovphysx = import_ovphysx() + ovphysx.bootstrap() + + carbonite_overrides = { + "/physics/physxDispatcher": True, + "/physics/updateToUsd": False, + "/physics/updateVelocitiesToUsd": False, + "/physics/updateParticlesToUsd": False, + } + if ovphysx_device == "gpu": + carbonite_overrides.update( + { + "/physics/suppressReadback": True, + "/physics/suppressFabricUpdate": True, + } + ) - physx_kwargs = {"device": ovphysx_device} - physx_signature = inspect.signature(ovphysx.PhysX) - physx_parameters = physx_signature.parameters - if "active_cuda_gpus" in physx_parameters: - if ovphysx_device == "gpu": - # ovphysx 0.4 accepts a comma-separated CUDA ordinal string; IsaacLab selects one GPU. - physx_kwargs["active_cuda_gpus"] = str(gpu_index) - physx_kwargs["config"] = ovphysx.PhysXConfig( - carbonite_overrides={ - "/physics/suppressReadback": True, - "/physics/suppressFabricUpdate": True, - } - ) - elif "gpu_index" in physx_parameters: - physx_kwargs["gpu_index"] = gpu_index + physx_kwargs = { + "device": ovphysx_device, + "config": ovphysx.PhysXConfig(num_threads=8, carbonite_overrides=carbonite_overrides), + } + if ovphysx_device == "gpu": + physx_kwargs["active_cuda_gpus"] = str(gpu_index) cls._physx = ovphysx.PhysX(**physx_kwargs) - # Without worker threads the stepper runs simulate()+fetchResults() - # synchronously, blocking the calling thread for the full GPU step time. - # - # COMPAT(ovphysx<=0.3.7): The public 0.3.7 wheel exposes typed config - # setters (set_config_int32 etc.) rather than the Carbonite-settings-based - # set_setting() added in newer internal builds. This guard keeps both - # working. REVERT once the public wheel ships set_setting(). - if hasattr(cls._physx, "set_setting"): - cls._physx.set_setting("/persistent/physics/numThreads", "8") - cls._physx.set_setting("/physics/physxDispatcher", "true") - cls._physx.set_setting("/physics/updateToUsd", "false") - cls._physx.set_setting("/physics/updateVelocitiesToUsd", "false") - cls._physx.set_setting("/physics/updateParticlesToUsd", "false") - else: - cls._physx.set_config_int32(ovphysx.ConfigInt32.NUM_THREADS, 8) - - # FIXME(malesiani): re-evaluate this when carbonite ships an isolated copy. - # At process exit, two Carbonite instances are in memory: - # 1. ovphysx's bundled libcarb.so (RPATH $ORIGIN/../plugins/) - # 2. kit's libcarb.so (pulled in via LD_LIBRARY_PATH by Fabric/usdrt plugins) - # - # Why does kit's libcarb end up here even though we skip AppLauncher? - # Note: AppLauncher always starts the full Kit runtime — even headless=True - # still loads Kit. "Kitless" in IsaacLab means AppLauncher is not used at all. - # But we still import `pxr` from IsaacSim's Kit USD build. The moment `import pxr` runs, the Kit USD - # runtime loads Fabric infrastructure (omni.physx.fabric.plugin, usdrt.population.plugin) - # from kit's plugin directories, which are on LD_LIBRARY_PATH via setup_python_env.sh. - # Those plugins link against kit's libcarb.so, so kit's Carbonite lands in memory - # purely from `import pxr`, regardless of whether the Kit App is launched. - # - # Both Carbonite instances register C++ static destructors. At process exit those - # destructors race and segfault. The workaround is to release ovphysx cleanly - # (so GPU resources are freed) and then call os._exit() to skip the static destructor - # phase entirely. os._exit() terminates the process without running C++ atexit - # handlers or static destructors, sidestepping the conflict. - # - # Proper long-term fix: ovphysx ships a fully namespace-isolated Carbonite - # (different soname / hidden visibility) so its symbols never collide with kit's. - if not cls._atexit_registered: - - def _atexit_release_and_exit(): - # Skip physx.release() -- it deadlocks due to dual-Carbonite - # static destructor races (ovphysx's bundled libcarb vs Kit's). - # GPU resources are reclaimed by the driver at process exit. - os._exit(0) - - atexit.register(_atexit_release_and_exit) - cls._atexit_registered = True - @staticmethod def _configure_physx_scene_prim(scene_prim, cfg, device: str) -> None: """Apply PhysxSceneAPI schema and device-specific scene attributes to the diff --git a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor.py b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor.py index 7e9b64760a2e..3baeceff9de8 100644 --- a/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor.py +++ b/source/isaaclab_ovphysx/isaaclab_ovphysx/sensors/contact_sensor/contact_sensor.py @@ -83,7 +83,7 @@ def __init__(self, cfg: ContactSensorCfg): if cfg.track_contact_points or cfg.track_friction_forces: raise NotImplementedError( "ovphysx ContactSensor does not yet support 'track_contact_points' or 'track_friction_forces'." - " ovphysx 0.3.7 lacks tensor-friendly per-sensor read APIs for these features." + " The IsaacLab OVPhysX adapter has not wired detailed contact/friction reads for these features." " See docs/superpowers/specs/2026-04-27-ovphysx-contact-api-gaps.md for the maintainer asks." ) diff --git a/source/isaaclab_ovphysx/pyproject.toml b/source/isaaclab_ovphysx/pyproject.toml index 2d8a8515decb..cc3aac04cbe7 100644 --- a/source/isaaclab_ovphysx/pyproject.toml +++ b/source/isaaclab_ovphysx/pyproject.toml @@ -23,7 +23,7 @@ Homepage = "https://github.com/isaac-sim/IsaacLab" Repository = "https://github.com/isaac-sim/IsaacLab" [project.optional-dependencies] -ovphysx = ["ovphysx==0.4.13"] +ovphysx = ["ovphysx>=0.5,<0.6"] [tool.setuptools] include-package-data = true diff --git a/source/isaaclab_ovphysx/test/assets/test_articulation.py b/source/isaaclab_ovphysx/test/assets/test_articulation.py index a3cc3f0583ed..19fefe44661e 100644 --- a/source/isaaclab_ovphysx/test/assets/test_articulation.py +++ b/source/isaaclab_ovphysx/test/assets/test_articulation.py @@ -12,8 +12,8 @@ Mirrors :mod:`isaaclab_physx.test.assets.test_articulation` 1-to-1: same set of test functions, names, parametrizations, and assertions. -OVPhysX runs kitless under ``./scripts/run_ovphysx.sh`` so there is no -``AppLauncher`` boot — :class:`~isaaclab.sim.SimulationContext` is driven +OVPhysX real-backend tests run kitless so there is no ``AppLauncher`` boot: +:class:`~isaaclab.sim.SimulationContext` is driven directly via ``build_simulation_context(sim_cfg=SimulationCfg(physics=OvPhysxCfg(), ...))`` which works because :func:`isaaclab.app.has_kit` returns False in this environment. @@ -40,11 +40,9 @@ CI note ------- Because the lock is process-global, full coverage requires **two separate -``./scripts/run_ovphysx.sh -m pytest`` invocations** -- once with ``-k 'cpu'`` -and once with ``-k 'cuda:0'``. Tracked as gap G5 in -``docs/superpowers/specs/2026-04-28-ovphysx-wheel-gaps-for-marco.md``; until -the wheel exposes a way to reset Carbonite device state, this is the supported -pattern. +``./isaaclab.sh -p -m pytest`` invocations** -- once with ``-k 'cpu'`` and +once with ``-k 'cuda:0'``. Until the wheel exposes a way to reset device mode, +this is the supported pattern. """ from __future__ import annotations diff --git a/source/isaaclab_ovphysx/test/assets/test_rigid_object.py b/source/isaaclab_ovphysx/test/assets/test_rigid_object.py index 046eb7b6e90f..d5cdfaffa24b 100644 --- a/source/isaaclab_ovphysx/test/assets/test_rigid_object.py +++ b/source/isaaclab_ovphysx/test/assets/test_rigid_object.py @@ -9,7 +9,7 @@ """Real-backend tests for the OVPhysX RigidObject. -Run via ``./scripts/run_ovphysx.sh -m pytest`` (kitless, no ``AppLauncher``). +Run via ``./isaaclab.sh -p -m pytest`` (kitless, no ``AppLauncher``). The OVPhysX runtime binds device mode (CPU vs GPU) at the C++ layer on the first ``ovphysx.PhysX(device=...)`` construction and cannot swap it without a diff --git a/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py b/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py index c19b2bfb7e92..adf66bfb7be9 100644 --- a/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py +++ b/source/isaaclab_ovphysx/test/assets/test_rigid_object_collection.py @@ -9,7 +9,7 @@ """Real-backend tests for the OVPhysX RigidObjectCollection. -Run via ``./scripts/run_ovphysx.sh -m pytest`` (kitless, no ``AppLauncher``). +Run via ``./isaaclab.sh -p -m pytest`` (kitless, no ``AppLauncher``). The OVPhysX runtime binds device mode (CPU vs GPU) at the C++ layer on the first ``ovphysx.PhysX(device=...)`` construction and cannot swap it without a diff --git a/source/isaaclab_ovphysx/test/physics/test_ovphysx_manager_lifecycle.py b/source/isaaclab_ovphysx/test/physics/test_ovphysx_manager_lifecycle.py new file mode 100644 index 000000000000..24a6efc4e10d --- /dev/null +++ b/source/isaaclab_ovphysx/test/physics/test_ovphysx_manager_lifecycle.py @@ -0,0 +1,155 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Guard against reintroducing old OvPhysX bootstrap and shutdown hacks. + +These tests do not try to prove that a real ovphysx wheel works. They use a +small fake ovphysx module to check IsaacLab's own control flow: + +- keep already-loaded ``pxr`` modules visible during ``ovphysx.bootstrap()``; +- do not install the old ``atexit -> os._exit(0)`` shutdown workaround; +- release the ``ovphysx.PhysX`` instance instead of resetting and keeping it. + +Those were temporary IsaacLab workarounds for missing namespaced USD and static +Carbonite support. They should stay removed now that ovphysx 0.5 owns those +parts itself. These tests intentionally do not cover CPU/GPU device-lock +behavior, because that runtime behavior is expected to keep evolving in ovphysx. +""" + +from __future__ import annotations + +import atexit +import sys +from types import ModuleType + +import pytest + +_MANAGER_STATE_FIELDS = ( + "_cfg", + "_physx", + "_usd_handle", + "_stage_path", + "_warmup_done", + "_tmp_dir", + "_locked_device", + "_pending_clones", + "_scene_data_backend", + "_physx_schemas_registered", +) + + +class _FakePhysXConfig: + def __init__(self, num_threads=None, carbonite_overrides=None): + self.num_threads = num_threads + self.carbonite_overrides = carbonite_overrides or {} + + +class _FakePhysX: + def __init__(self, device=None, active_cuda_gpus=None, config=None): + self.device = device + self.active_cuda_gpus = active_cuda_gpus + self.config = config + self.released = False + self.reset_called = False + self.waited_ops: list[object] = [] + + def reset(self): + self.reset_called = True + return object() + + def wait_op(self, op) -> None: + self.waited_ops.append(op) + + def release(self) -> None: + self.released = True + + +@pytest.fixture +def ovphysx_manager_module(): + """Import the manager module and restore class-global state after the test.""" + import isaaclab_ovphysx.physics.ovphysx_manager as module + + manager = module.OvPhysxManager + saved = {} + for name in _MANAGER_STATE_FIELDS: + if hasattr(manager, name): + value = getattr(manager, name) + saved[name] = list(value) if name == "_pending_clones" else value + try: + manager._cfg = None + manager._physx = None + manager._usd_handle = None + manager._stage_path = None + manager._warmup_done = False + manager._tmp_dir = None + manager._locked_device = None + manager._pending_clones = [] + manager._scene_data_backend = None + manager._physx_schemas_registered = False + yield module + finally: + current_tmp_dir = manager._tmp_dir + saved_tmp_dir = saved.get("_tmp_dir") + if current_tmp_dir is not None and current_tmp_dir is not saved_tmp_dir: + current_tmp_dir.cleanup() + for name, value in saved.items(): + setattr(manager, name, value) + + +def _fake_ovphysx_module(bootstrap): + module = ModuleType("ovphysx") + module.bootstrap = bootstrap + module.PhysX = _FakePhysX + module.PhysXConfig = _FakePhysXConfig + return module + + +def test_construct_physx_bootstrap_keeps_loaded_pxr_modules(monkeypatch, ovphysx_manager_module): + """Namespaced USD means ovphysx bootstrap no longer needs IsaacLab to hide host ``pxr``.""" + host_pxr = ModuleType("pxr") + host_usd = ModuleType("pxr.Usd") + monkeypatch.setitem(sys.modules, "pxr", host_pxr) + monkeypatch.setitem(sys.modules, "pxr.Usd", host_usd) + + def bootstrap(): + assert sys.modules.get("pxr") is host_pxr + assert sys.modules.get("pxr.Usd") is host_usd + + monkeypatch.setattr( + ovphysx_manager_module, "import_ovphysx", lambda module_name="ovphysx": _fake_ovphysx_module(bootstrap) + ) + + ovphysx_manager_module.OvPhysxManager._construct_physx("cpu", 0) + + assert sys.modules["pxr"] is host_pxr + assert sys.modules["pxr.Usd"] is host_usd + + +def test_construct_physx_does_not_register_forced_process_exit(monkeypatch, ovphysx_manager_module): + """Static-carb ovphysx owns shutdown; IsaacLab must not install an ``os._exit`` atexit hook.""" + registrations = [] + assert not hasattr(ovphysx_manager_module.OvPhysxManager, "_atexit_registered") + + monkeypatch.setattr(atexit, "register", lambda callback: registrations.append(callback)) + monkeypatch.setattr( + ovphysx_manager_module, "import_ovphysx", lambda module_name="ovphysx": _fake_ovphysx_module(lambda: None) + ) + + ovphysx_manager_module.OvPhysxManager._construct_physx("cpu", 0) + + assert registrations == [] + + +def test_release_physx_releases_instance_and_clears_reference(ovphysx_manager_module): + """Manager close should release ovphysx instead of keeping a stale workaround instance alive.""" + fake_physx = _FakePhysX(device="cpu") + ovphysx_manager_module.OvPhysxManager._physx = fake_physx + + ovphysx_manager_module.OvPhysxManager._release_physx() + + assert fake_physx.released is True + assert fake_physx.reset_called is False + assert fake_physx.waited_ops == [] + assert ovphysx_manager_module.OvPhysxManager._physx is None diff --git a/source/isaaclab_ovphysx/test/sensors/test_contact_sensor.py b/source/isaaclab_ovphysx/test/sensors/test_contact_sensor.py index a60f7d0424a1..060e78f36c17 100644 --- a/source/isaaclab_ovphysx/test/sensors/test_contact_sensor.py +++ b/source/isaaclab_ovphysx/test/sensors/test_contact_sensor.py @@ -8,9 +8,8 @@ """Real-backend tests for the OVPhysX ContactSensor. -Run via ``./isaaclab.sh -p -m pytest``; the ovphysx wheel is now invocable -through the standard Kit Python entrypoint, so the older kitless -``./scripts/run_ovphysx.sh`` wrapper is no longer required. +Run via ``./isaaclab.sh -p -m pytest``; the ovphysx wheel is invocable through +the standard Kit Python entrypoint. The OVPhysX runtime binds device mode (CPU vs GPU) at the C++ layer on the first ``ovphysx.PhysX(device=...)`` construction and cannot swap it without a diff --git a/source/isaaclab_ovphysx/test/sim/test_views_xform_prim_ovphysx.py b/source/isaaclab_ovphysx/test/sim/test_views_xform_prim_ovphysx.py index e606ce7738b9..2365b0dc452f 100644 --- a/source/isaaclab_ovphysx/test/sim/test_views_xform_prim_ovphysx.py +++ b/source/isaaclab_ovphysx/test/sim/test_views_xform_prim_ovphysx.py @@ -5,7 +5,7 @@ """Real-backend tests for the OVPhysX FrameView. -Run via ``./scripts/run_ovphysx.sh -m pytest`` (kitless, no ``AppLauncher``). +Run via ``./isaaclab.sh -p -m pytest`` (kitless, no ``AppLauncher``). """ from __future__ import annotations