fix(gui): make Quit button actually terminate the server

Signalling the process with SIGTERM/SIGINT didn't reliably shut Streamlit
down — its tornado/asyncio loop swallowed or deferred the signal, so the
browser saw the websocket drop ("Connection error") while the python
process kept running. Replace the signal with a daemon-thread
``os._exit(0)`` after a short delay so the current rerun can paint the
"shutting down" message before the process is hard-killed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 13:36:36 +00:00
parent 30e257cc44
commit 58c0195def

View File

@@ -4,7 +4,8 @@ from __future__ import annotations
import io import io
import os import os
import signal import threading
import time
from typing import Optional from typing import Optional
import pandas as pd 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: def quit_button(label: str = "Quit app", *, key: str = "quit_app_button") -> None:
"""Render a Quit button that terminates the Streamlit server. """Render a Quit button that terminates the Streamlit server.
Streamlit has no first-class shutdown hook, so closing the browser tab Streamlit has no first-class shutdown hook, and signalling the
leaves the server (and the python process running it) alive — the user process (SIGTERM/SIGINT) does not reliably terminate it — Streamlit
has to Ctrl+C in the shell. This helper signals the Streamlit process installs its own handlers and the tornado/asyncio loop swallows or
so the shell returns cleanly. SIGTERM lets Streamlit run its own defers the signal, so the browser sees the websocket drop while the
shutdown handlers; if that's unavailable on the platform, fall back to python process stays alive. To shut down cleanly we schedule
SIGINT (the same signal Ctrl+C delivers). ``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"): 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() st.stop()
if st.button(label, key=key, type="secondary"): if st.button(label, key=key, type="secondary"):
st.session_state["_app_shutting_down"] = True 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() st.rerun()