feat(dashboard): auto-stop server after 5 minutes idle
All checks were successful
CI / Lint (push) Successful in 4s
CI / Type Check (push) Successful in 18s
CI / Test (push) Successful in 56s
CI / Release (push) Has been skipped

Replace pause-on-idle with full server shutdown after IDLE_SHUTDOWN_SECONDS
(default 5 minutes). Next visitor gets a fresh simulation instance.

- Idle checker stops server and clears st.cache_resource
- init_session_state detects stopped server and recreates fresh state
- Clears instruments and history for clean restart

Configurable via IDLE_SHUTDOWN_SECONDS environment variable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 23:57:23 +00:00
parent b826337b36
commit 1ec05ea289

View File

@@ -28,26 +28,49 @@ from py_dvt_ate.tests.thermal.tempco import TempCoTest
_test_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="test_runner")
# 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"))
# Idle shutdown configuration
# Server stops after IDLE_SHUTDOWN_SECONDS of no activity (default: 5 minutes)
# Next visitor gets a fresh simulation instance
IDLE_SHUTDOWN_SECONDS = int(os.environ.get("IDLE_SHUTDOWN_SECONDS", "300"))
_last_activity_time: float = time.time()
_idle_checker_started = False
_server_ref: SimulationServer | None = None # Reference for idle checker thread
def _idle_checker() -> None:
"""Background thread that pauses physics when idle."""
"""Background thread that stops server when idle."""
global _last_activity_time, _server_ref
while True:
time.sleep(5) # Check every 5 seconds
time.sleep(10) # Check every 10 seconds
if _server_ref is None:
continue
idle_seconds = time.time() - _last_activity_time
if idle_seconds > IDLE_PAUSE_SECONDS and not _server_ref.paused:
_server_ref.paused = True
print(f"Physics engine paused (idle for {idle_seconds:.0f}s)")
if idle_seconds > IDLE_SHUTDOWN_SECONDS:
print(f"Stopping server (idle for {idle_seconds:.0f}s)")
_stop_server()
break # Exit checker thread - new one starts with new server
def _stop_server() -> None:
"""Stop the server and clear caches for fresh restart."""
global _server_ref, _idle_checker_started
if _server_ref is not None:
# Stop the server
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(_server_ref.stop())
except Exception:
pass
finally:
loop.close()
_server_ref = None
# Clear Streamlit's cached server so next visitor gets fresh instance
get_or_create_server.clear()
# Reset idle checker flag so new one can start
_idle_checker_started = False
def _start_idle_checker(server: SimulationServer) -> None:
@@ -161,19 +184,21 @@ def get_or_create_server() -> SimulationServer:
def _update_activity() -> None:
"""Update activity timestamp and resume physics if paused."""
"""Update activity timestamp to prevent idle shutdown."""
global _last_activity_time
_last_activity_time = time.time()
# Resume physics if it was paused
server = st.session_state.get("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."""
# Check if existing server was stopped by idle checker
if "server" in st.session_state and st.session_state.server is not None:
if not st.session_state.server.is_running:
# Server was stopped - clear stale state for fresh start
st.session_state.server = None
st.session_state.pop("instruments", None)
st.session_state.pop("history", None)
# Get or create the server singleton (survives Streamlit reruns via st.cache_resource)
if "server" not in st.session_state or st.session_state.server is None:
with st.spinner("Starting simulation server..."):
@@ -189,7 +214,7 @@ def init_session_state() -> None:
st.error("Failed to start simulation server. Please refresh the page.")
st.stop()
# Start idle checker to pause physics when no one's viewing
# Start idle checker to stop server when no one's viewing
_start_idle_checker(st.session_state.server)
if "instruments" not in st.session_state: