diff --git a/src/py_dvt_ate/app/dashboard/app.py b/src/py_dvt_ate/app/dashboard/app.py index c513098..27fd289 100644 --- a/src/py_dvt_ate/app/dashboard/app.py +++ b/src/py_dvt_ate/app/dashboard/app.py @@ -28,6 +28,12 @@ from py_dvt_ate.tests.thermal.tempco import TempCoTest # Thread pool for background test execution _test_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="test_runner") +# Module-level singleton for simulation server +# This ensures only one server instance exists across all Streamlit reruns, +# preventing "address already in use" errors when the page refreshes +_simulation_server: SimulationServer | None = None +_server_thread: threading.Thread | None = None + # Idle pause configuration # Physics engine pauses after IDLE_PAUSE_SECONDS of no activity (default: 30s) IDLE_PAUSE_SECONDS = int(os.environ.get("IDLE_PAUSE_SECONDS", "30")) @@ -101,12 +107,22 @@ class TestProgress: return time.time() - self.started_at -def start_embedded_server() -> tuple[SimulationServer, threading.Thread]: - """Start an embedded simulation server in a background thread. +def get_or_create_server() -> SimulationServer: + """Get or create the simulation server singleton. + + Uses module-level singleton to ensure only one server instance exists + across all Streamlit reruns, preventing "address already in use" errors. Returns: - Tuple of (server instance, thread running the server). + The simulation server instance. """ + global _simulation_server, _server_thread + + # Return existing server if it's running + if _simulation_server is not None and _simulation_server.is_running: + return _simulation_server + + # Create new server server = SimulationServer( ServerConfig( host="127.0.0.1", @@ -151,27 +167,34 @@ def start_embedded_server() -> tuple[SimulationServer, threading.Thread]: if server_error: st.error(f"Server startup error: {server_error[0]}") - return server, thread + # Store in module-level singleton + _simulation_server = server + _server_thread = thread + + return server def _update_activity() -> None: """Update activity timestamp and resume physics if paused.""" - global _last_activity_time + global _last_activity_time, _simulation_server _last_activity_time = time.time() - # Resume physics if it was paused - if "server" in st.session_state: - server = st.session_state.server - if server.paused: - server.paused = False - print("Physics engine resumed (user activity detected)") + # Resume physics if it was paused (use singleton as fallback) + server = st.session_state.get("server") or _simulation_server + if server is not None and server.paused: + server.paused = False + print("Physics engine resumed (user activity detected)") def init_session_state() -> None: """Initialise Streamlit session state.""" - if "server" not in st.session_state: + global _simulation_server + + # Get or create the server singleton (survives Streamlit reruns) + if "server" not in st.session_state or st.session_state.server is None: with st.spinner("Starting simulation server..."): - st.session_state.server, st.session_state.server_thread = start_embedded_server() + server = get_or_create_server() + st.session_state.server = server # Verify server started correctly if st.session_state.server.physics_engine is None: @@ -181,15 +204,17 @@ def init_session_state() -> None: # Start idle checker to pause physics when no one's viewing _start_idle_checker(st.session_state.server) - # Register cleanup + # Register cleanup (only once, for the singleton) def cleanup() -> None: - if hasattr(st.session_state, "server") and st.session_state.server is not None: + global _simulation_server + if _simulation_server is not None: loop = asyncio.new_event_loop() try: - loop.run_until_complete(st.session_state.server.stop()) + loop.run_until_complete(_simulation_server.stop()) except Exception: pass loop.close() + _simulation_server = None atexit.register(cleanup) if "instruments" not in st.session_state: