From 728774a15858432da4839daf94c05e9e27da0f1a Mon Sep 17 00:00:00 2001 From: wu-simulab Date: Thu, 20 Aug 2026 17:20:38 +0800 Subject: [PATCH 1/2] fix(sim): make tutorial shutdown interrupt-safe Close the native render window before scene teardown, release interrupted tutorial frames before destroying borrowed native resources, and safely discard partially constructed managers. Add focused regression coverage for normal, interrupted, and failed initialization paths. --- embodichain/lab/sim/sim_manager.py | 9 ++ .../tutorials/atomic_action/tutorial_utils.py | 26 +++- .../test_atomic_action_tutorial_utils.py | 130 ++++++++++++++++++ 3 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 tests/lab/scripts/test_atomic_action_tutorial_utils.py diff --git a/embodichain/lab/sim/sim_manager.py b/embodichain/lab/sim/sim_manager.py index c8d1cf875..5f4fc6977 100644 --- a/embodichain/lab/sim/sim_manager.py +++ b/embodichain/lab/sim/sim_manager.py @@ -255,6 +255,7 @@ def __new__(cls, sim_config: SimulationManagerCfg = SimulationManagerCfg()): instance = super(SimulationManager, cls).__new__(cls) # Store sim_config in the instance for use in __init__ or elsewhere instance.sim_config = sim_config + instance._is_constructed = False cls._instances[n_instance] = instance return instance @@ -378,6 +379,8 @@ def __init__( if sim_config.headless is False: self._window = self._world.get_windows() + self._is_constructed = True + @classmethod def get_instance(cls, instance_id: int = 0) -> SimulationManager: """Get the instance of SimulationManager by id. @@ -3117,6 +3120,12 @@ def _deferred_destroy(self) -> None: self.stop_window_record() self.wait_window_record_saves() + # Stop the render loop before releasing scene resources. Vulkan window + # presentation may otherwise continue acquiring swapchain images while + # Env::Clean tears down render objects used by the in-flight frame. + if getattr(self, "is_window_opened", False): + self.close_window() + import sys, gc self.clean_materials() diff --git a/scripts/tutorials/atomic_action/tutorial_utils.py b/scripts/tutorials/atomic_action/tutorial_utils.py index f90c1b127..8165f8ff3 100644 --- a/scripts/tutorials/atomic_action/tutorial_utils.py +++ b/scripts/tutorials/atomic_action/tutorial_utils.py @@ -230,16 +230,30 @@ def run_tutorial(main: Callable[[], None]) -> None: Args: main: Zero-argument tutorial entry point. """ + interrupted = False try: - main() + try: + main() + except KeyboardInterrupt: + # Handle Ctrl+C before native cleanup. An active traceback keeps + # main() locals (including borrowed C++ material wrappers) alive; + # destroying World first would make their later destructors unsafe. + interrupted = True + logger.log_info("Tutorial interrupted; shutting down cleanly.") finally: if SimulationManager.is_instantiated(): sim = SimulationManager.get_instance() - if sim.is_window_recording(): - sim.stop_window_record() - sim.wait_window_record_saves() - sim.destroy(exit_process=False) - SimulationManager.flush_cleanup_queue() + if not getattr(sim, "_is_constructed", False): + SimulationManager.reset(getattr(sim, "instance_id", 0)) + else: + if sim.is_window_recording(): + sim.stop_window_record() + sim.wait_window_record_saves() + sim.destroy(exit_process=False) + SimulationManager.flush_cleanup_queue() + + if interrupted: + raise SystemExit(130) def add_ur5_gripper_robot( diff --git a/tests/lab/scripts/test_atomic_action_tutorial_utils.py b/tests/lab/scripts/test_atomic_action_tutorial_utils.py new file mode 100644 index 000000000..0fb6daa04 --- /dev/null +++ b/tests/lab/scripts/test_atomic_action_tutorial_utils.py @@ -0,0 +1,130 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from embodichain.lab.sim import SimulationManager +from scripts.tutorials.atomic_action.tutorial_utils import run_tutorial + +pytestmark = pytest.mark.no_sim + + +def _patch_manager( + monkeypatch: pytest.MonkeyPatch, sim: object +) -> tuple[MagicMock, MagicMock]: + reset = MagicMock() + flush_cleanup_queue = MagicMock() + monkeypatch.setattr( + SimulationManager, + "is_instantiated", + classmethod(lambda cls: True), + ) + monkeypatch.setattr( + SimulationManager, + "get_instance", + classmethod(lambda cls: sim), + ) + monkeypatch.setattr( + SimulationManager, + "reset", + classmethod(lambda cls, instance_id=0: reset(instance_id)), + ) + monkeypatch.setattr( + SimulationManager, + "flush_cleanup_queue", + staticmethod(flush_cleanup_queue), + ) + return reset, flush_cleanup_queue + + +def test_run_tutorial_releases_interrupted_locals_before_cleanup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[str] = [] + sim = MagicMock(_is_constructed=True) + sim.is_window_recording.return_value = False + sim.wait_window_record_saves.side_effect = lambda: events.append("wait") + sim.destroy.side_effect = lambda **_: events.append("destroy") + _, flush_cleanup_queue = _patch_manager(monkeypatch, sim) + flush_cleanup_queue.side_effect = lambda: events.append("flush") + + class BorrowedNativeWrapper: + def __del__(self) -> None: + events.append("borrower-released") + + def interrupt_with_live_wrapper() -> None: + borrowed_wrapper = BorrowedNativeWrapper() + assert borrowed_wrapper is not None + raise KeyboardInterrupt + + with pytest.raises(SystemExit) as interrupted: + run_tutorial(interrupt_with_live_wrapper) + + assert interrupted.value.code == 130 + assert events == ["borrower-released", "wait", "destroy", "flush"] + + +def test_run_tutorial_stops_recording_before_normal_cleanup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sim = MagicMock(_is_constructed=True) + sim.is_window_recording.return_value = True + _, flush_cleanup_queue = _patch_manager(monkeypatch, sim) + + run_tutorial(lambda: None) + + sim.stop_window_record.assert_called_once_with() + sim.wait_window_record_saves.assert_called_once_with() + sim.destroy.assert_called_once_with(exit_process=False) + flush_cleanup_queue.assert_called_once_with() + + +def test_run_tutorial_resets_partially_constructed_manager_on_interrupt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sim = SimpleNamespace(_is_constructed=False, instance_id=0) + reset, flush_cleanup_queue = _patch_manager(monkeypatch, sim) + + def interrupt_during_construction() -> None: + raise KeyboardInterrupt + + with pytest.raises(SystemExit) as interrupted: + run_tutorial(interrupt_during_construction) + + assert interrupted.value.code == 130 + reset.assert_called_once_with(0) + flush_cleanup_queue.assert_not_called() + + +def test_run_tutorial_preserves_non_interrupt_exceptions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sim = SimpleNamespace(_is_constructed=False, instance_id=0) + reset, flush_cleanup_queue = _patch_manager(monkeypatch, sim) + + def fail_during_construction() -> None: + raise RuntimeError("construction failed") + + with pytest.raises(RuntimeError, match="construction failed"): + run_tutorial(fail_during_construction) + + reset.assert_called_once_with(0) + flush_cleanup_queue.assert_not_called() From d7d3712332b58636cc4b4d9252d9c20aecf4171c Mon Sep 17 00:00:00 2001 From: yuecideng Date: Thu, 20 Aug 2026 18:43:34 +0800 Subject: [PATCH 2/2] update --- .../test_atomic_action_tutorial_utils.py | 130 ------------------ 1 file changed, 130 deletions(-) delete mode 100644 tests/lab/scripts/test_atomic_action_tutorial_utils.py diff --git a/tests/lab/scripts/test_atomic_action_tutorial_utils.py b/tests/lab/scripts/test_atomic_action_tutorial_utils.py deleted file mode 100644 index 0fb6daa04..000000000 --- a/tests/lab/scripts/test_atomic_action_tutorial_utils.py +++ /dev/null @@ -1,130 +0,0 @@ -# ---------------------------------------------------------------------------- -# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# ---------------------------------------------------------------------------- - -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import MagicMock - -import pytest - -from embodichain.lab.sim import SimulationManager -from scripts.tutorials.atomic_action.tutorial_utils import run_tutorial - -pytestmark = pytest.mark.no_sim - - -def _patch_manager( - monkeypatch: pytest.MonkeyPatch, sim: object -) -> tuple[MagicMock, MagicMock]: - reset = MagicMock() - flush_cleanup_queue = MagicMock() - monkeypatch.setattr( - SimulationManager, - "is_instantiated", - classmethod(lambda cls: True), - ) - monkeypatch.setattr( - SimulationManager, - "get_instance", - classmethod(lambda cls: sim), - ) - monkeypatch.setattr( - SimulationManager, - "reset", - classmethod(lambda cls, instance_id=0: reset(instance_id)), - ) - monkeypatch.setattr( - SimulationManager, - "flush_cleanup_queue", - staticmethod(flush_cleanup_queue), - ) - return reset, flush_cleanup_queue - - -def test_run_tutorial_releases_interrupted_locals_before_cleanup( - monkeypatch: pytest.MonkeyPatch, -) -> None: - events: list[str] = [] - sim = MagicMock(_is_constructed=True) - sim.is_window_recording.return_value = False - sim.wait_window_record_saves.side_effect = lambda: events.append("wait") - sim.destroy.side_effect = lambda **_: events.append("destroy") - _, flush_cleanup_queue = _patch_manager(monkeypatch, sim) - flush_cleanup_queue.side_effect = lambda: events.append("flush") - - class BorrowedNativeWrapper: - def __del__(self) -> None: - events.append("borrower-released") - - def interrupt_with_live_wrapper() -> None: - borrowed_wrapper = BorrowedNativeWrapper() - assert borrowed_wrapper is not None - raise KeyboardInterrupt - - with pytest.raises(SystemExit) as interrupted: - run_tutorial(interrupt_with_live_wrapper) - - assert interrupted.value.code == 130 - assert events == ["borrower-released", "wait", "destroy", "flush"] - - -def test_run_tutorial_stops_recording_before_normal_cleanup( - monkeypatch: pytest.MonkeyPatch, -) -> None: - sim = MagicMock(_is_constructed=True) - sim.is_window_recording.return_value = True - _, flush_cleanup_queue = _patch_manager(monkeypatch, sim) - - run_tutorial(lambda: None) - - sim.stop_window_record.assert_called_once_with() - sim.wait_window_record_saves.assert_called_once_with() - sim.destroy.assert_called_once_with(exit_process=False) - flush_cleanup_queue.assert_called_once_with() - - -def test_run_tutorial_resets_partially_constructed_manager_on_interrupt( - monkeypatch: pytest.MonkeyPatch, -) -> None: - sim = SimpleNamespace(_is_constructed=False, instance_id=0) - reset, flush_cleanup_queue = _patch_manager(monkeypatch, sim) - - def interrupt_during_construction() -> None: - raise KeyboardInterrupt - - with pytest.raises(SystemExit) as interrupted: - run_tutorial(interrupt_during_construction) - - assert interrupted.value.code == 130 - reset.assert_called_once_with(0) - flush_cleanup_queue.assert_not_called() - - -def test_run_tutorial_preserves_non_interrupt_exceptions( - monkeypatch: pytest.MonkeyPatch, -) -> None: - sim = SimpleNamespace(_is_constructed=False, instance_id=0) - reset, flush_cleanup_queue = _patch_manager(monkeypatch, sim) - - def fail_during_construction() -> None: - raise RuntimeError("construction failed") - - with pytest.raises(RuntimeError, match="construction failed"): - run_tutorial(fail_during_construction) - - reset.assert_called_once_with(0) - flush_cleanup_queue.assert_not_called()