From fb43bca7fb201e1b131a5de13eba9bde1847ce0f Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:20:03 -0700 Subject: [PATCH] Finish owned native demo cleanup despite repeated stop signals --- .../prototype/native_prepared_showcase.py | 35 +++++++++++++++---- roadscore/prototype/test_native_cleanup.py | 21 +++++++++++ 2 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 roadscore/prototype/test_native_cleanup.py diff --git a/roadscore/prototype/native_prepared_showcase.py b/roadscore/prototype/native_prepared_showcase.py index f6aec4777d..bb86ee62bb 100644 --- a/roadscore/prototype/native_prepared_showcase.py +++ b/roadscore/prototype/native_prepared_showcase.py @@ -98,6 +98,32 @@ def worker_arguments(project, root, out, archive, route, muted, curve_plan=None, return args +def cleanup_owned(children, display, logs): + """Finish owned cleanup even if the parent sends a second stop signal.""" + previous = {sig: signal.signal(sig, signal.SIG_IGN) for sig in (signal.SIGTERM, signal.SIGINT)} + errors = [] + try: + for child in reversed(list(children.values())): + try: + if child.poll() is None: + try: os.killpg(child.pid, signal.SIGTERM) + except ProcessLookupError: pass + try: child.wait(timeout=5) + except subprocess.TimeoutExpired: + try: os.killpg(child.pid, signal.SIGKILL) + except ProcessLookupError: pass + child.wait(timeout=5) + except Exception as error: + errors.append(type(error).__name__ + ': ' + str(error)) + finally: + try: + if display is not None: display.close() + finally: + for log in logs: log.close() + for sig, handler in previous.items(): signal.signal(sig, handler) + return errors + + def main(): p=argparse.ArgumentParser(description=__doc__) p.add_argument('--roadscore',action='store_true');p.add_argument('--demo',action='store_true') @@ -220,13 +246,8 @@ def main(): write_json(out/'demo_failure.json',dict(session_id=session,error=failure,wall=time.monotonic())) raise finally: - for child in reversed(list(children.values())): - if child.poll() is None: - os.killpg(child.pid,signal.SIGTERM) - try:child.wait(timeout=5) - except subprocess.TimeoutExpired:os.killpg(child.pid,signal.SIGKILL);child.wait() - if display is not None:display.close() - for log in logs:log.close() + cleanup_errors=cleanup_owned(children,display,logs) + if cleanup_errors:write_json(out/'cleanup_errors.json',dict(errors=cleanup_errors)) write_json(out/'status.json',dict(readiness='DEGRADED' if failure else 'COLD',compute='prepared-core',route=route,presentation_session_id=session,command_wall=time.monotonic(),input_mode='stopped',error=failure)) print('Prepared native demo stopped. Existing composer ownership unchanged.',flush=True) diff --git a/roadscore/prototype/test_native_cleanup.py b/roadscore/prototype/test_native_cleanup.py new file mode 100644 index 0000000000..d373263ef1 --- /dev/null +++ b/roadscore/prototype/test_native_cleanup.py @@ -0,0 +1,21 @@ +import os +import signal +import unittest +from unittest.mock import Mock, patch +from native_prepared_showcase import cleanup_owned + +class CleanupTests(unittest.TestCase): + def test_second_stop_during_wait_does_not_skip_display(self): + child=Mock(pid=123);child.poll.return_value=None + child.wait.side_effect=lambda **kw:os.kill(os.getpid(),signal.SIGTERM) + display=Mock();log=Mock();old=signal.getsignal(signal.SIGTERM) + with patch('native_prepared_showcase.os.killpg'): + self.assertEqual(cleanup_owned({'ui':child},display,[log]),[]) + display.close.assert_called_once();log.close.assert_called_once() + self.assertEqual(signal.getsignal(signal.SIGTERM),old) + def test_failed_child_does_not_skip_other_children_or_display(self): + bad=Mock(pid=1);bad.poll.side_effect=OSError('gone') + good=Mock(pid=2);good.poll.return_value=None;display=Mock() + with patch('native_prepared_showcase.os.killpg'): + self.assertEqual(len(cleanup_owned({'good':good,'bad':bad},display,[])),1) + good.wait.assert_called_once();display.close.assert_called_once()