fix(dashboard): use module-level singleton to prevent port conflicts on refresh
When Streamlit refreshes/reruns, session state is lost but the old simulation server thread keeps running on ports 5001-5003. This caused "address already in use" errors when trying to start a new server. Solution: Use a module-level singleton for the simulation server that persists across Streamlit reruns. The get_or_create_server() function checks if a server is already running before creating a new one. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user