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 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()