diff --git a/src/gui/components/_legacy.py b/src/gui/components/_legacy.py index fc8ecf9..fa03da9 100644 --- a/src/gui/components/_legacy.py +++ b/src/gui/components/_legacy.py @@ -4,7 +4,8 @@ from __future__ import annotations import io import os -import signal +import threading +import time from typing import Optional import pandas as pd @@ -90,21 +91,27 @@ def hide_streamlit_chrome() -> None: def quit_button(label: str = "Quit app", *, key: str = "quit_app_button") -> None: """Render a Quit button that terminates the Streamlit server. - Streamlit has no first-class shutdown hook, so closing the browser tab - leaves the server (and the python process running it) alive — the user - has to Ctrl+C in the shell. This helper signals the Streamlit process - so the shell returns cleanly. SIGTERM lets Streamlit run its own - shutdown handlers; if that's unavailable on the platform, fall back to - SIGINT (the same signal Ctrl+C delivers). + Streamlit has no first-class shutdown hook, and signalling the + process (SIGTERM/SIGINT) does not reliably terminate it — Streamlit + installs its own handlers and the tornado/asyncio loop swallows or + defers the signal, so the browser sees the websocket drop while the + python process stays alive. To shut down cleanly we schedule + ``os._exit(0)`` on a daemon thread after a short delay; the delay + lets the current rerun finish painting the "shutting down" message + before the process is hard-killed. """ if st.session_state.get("_app_shutting_down"): - st.success("App shut down. You can close this browser tab.") + st.success("Shutting down… you can close this browser tab.") st.stop() if st.button(label, key=key, type="secondary"): st.session_state["_app_shutting_down"] = True - sig = getattr(signal, "SIGTERM", signal.SIGINT) - os.kill(os.getpid(), sig) + + def _hard_exit() -> None: + time.sleep(0.5) + os._exit(0) + + threading.Thread(target=_hard_exit, daemon=True).start() st.rerun()