fix(dashboard): use module-level singleton to prevent port conflicts on refresh
All checks were successful
CI / Lint (push) Successful in 4s
CI / Type Check (push) Successful in 20s
CI / Test (push) Successful in 56s
CI / Release (push) Has been skipped

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:
2026-01-29 23:21:35 +00:00
parent 66cdd4494c
commit bc15df3051

View File

@@ -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:
# 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: