Skip to content

Implement Auto-Shutdown When Browser Closes (solves Issue #151)#166

Closed
Ayushmaan06 wants to merge 4 commits intoOpenMS:mainfrom
Ayushmaan06:main
Closed

Implement Auto-Shutdown When Browser Closes (solves Issue #151)#166
Ayushmaan06 wants to merge 4 commits intoOpenMS:mainfrom
Ayushmaan06:main

Conversation

@Ayushmaan06
Copy link

@Ayushmaan06 Ayushmaan06 commented Mar 10, 2025

Solves #151
This PR introduces an auto‑shutdown mechanism for the Streamlit server. Key changes include:

• Adding Tornado endpoints (/heartbeat and /_closeapp) that handle periodic heartbeat pings and browser unload events.
• Implementing a heartbeat monitor in a background thread that shuts down the server if no heartbeat is received for 10 seconds and no active sessions exist.
• Injecting client‑side JavaScript (using sendBeacon on window unload and a regular heartbeat script) to notify the server when the browser closes.

These changes ensure that the Streamlit process terminates automatically when the browser is closed, providing a better user experience, especially on Windows when deployed as an executable.

Summary by CodeRabbit

Summary by CodeRabbit

  • New Features
    • Introduced enhanced session management to monitor active user activity.
    • Implemented an automatic shutdown mechanism when no active sessions or activity are detected.
    • Improved client behavior with periodic heartbeat signals and tab close handling to ensure accurate session tracking.
    • Added support for local execution configuration with a new environment variable.
    • Updated dependencies to include Tornado for improved session handling.

@coderabbitai
Copy link

coderabbitai bot commented Mar 10, 2025

Walkthrough

This change introduces a global session management system within the Streamlit application. It adds global variables to track active sessions and last heartbeat timestamps. Functions are defined to increment and decrement session counts and invoke server shutdown when no sessions remain. Tornado web handlers are added to process heartbeat pings and handle session closures. A separate Tornado server thread along with a heartbeat monitor thread ensures inactivity is detected, while client-side JavaScript sends periodic heartbeat pings and manages tab closure events.

Changes

File Changes Summary
app.py - Added global variables: global_active_sessions, last_heartbeat, lock
- Added functions: increment_active_sessions, decrement_active_sessions, shutdown, start_tornado_server, heartbeat_monitor, insert_heartbeat_script
- Introduced Tornado handlers: CloseAppHandler, HeartbeatHandler
- Integrated client-side JavaScript for heartbeat pings and tab closure events
.env - New environment variable added: LOCAL_RUN with value true
requirements.txt - New dependency added: tornado>=6.2.0

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client Browser (JS)
    participant Tornado as Tornado Server
    participant Session as Session Manager
    participant Monitor as Heartbeat Monitor

    Client->>Tornado: Sends heartbeat ping every 3s
    Tornado->>Session: Update last_heartbeat timestamp
    Client->>Tornado: Sends tab close event on exit
    Tornado->>Session: Decrement active session count
    Monitor->>Session: Check last_heartbeat & active sessions
    Session-->>Tornado: Trigger shutdown if no active sessions and inactive heartbeat
Loading

Poem

I’m a little rabbit, hopping through code,
Tracking sessions till all loads are towed.
Heartbeats ping in a cheerful dance,
Tabs closing—no chance for mischance.
With a twitch of my nose and a happy cheer,
I celebrate smooth changes in our app sphere!
🐇💻

Tip

⚡🧪 Multi-step agentic review comment chat (experimental)
  • We're introducing multi-step agentic chat in review comments. This experimental feature enhances review discussions with the CodeRabbit agentic chat by enabling advanced interactions, including the ability to create pull requests directly from comments.
    - To enable this feature, set early_access to true under in the settings.

📜 Recent review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7b1e4e2 and 5e61114.

📒 Files selected for processing (1)
  • requirements.txt (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • requirements.txt
⏰ Context from checks skipped due to timeout of 90000ms (4)
  • GitHub Check: build
  • GitHub Check: build-simple-app
  • GitHub Check: build-full-app
  • GitHub Check: build-openms

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (7)
app.py (7)

11-11: Unused import flagged by static analysis.

Since pyopenms may be unused, consider removing it to keep the code clean. However, if it’s needed for side effects or platform-specific fixes (as the comment suggests), feel free to ignore this suggestion:

-import pyopenms  # required import for Windows
🧰 Tools
🪛 Ruff (0.8.2)

11-11: pyopenms imported but unused

Remove unused import: pyopenms

(F401)


15-16: Consider adding error handling when loading settings.

Wrap file operations in a try-except block to handle missing or invalid JSON content gracefully.

 try:
     with open("settings.json", "r") as f:
         st.session_state.settings = json.load(f)
 except (FileNotFoundError, json.JSONDecodeError) as e:
     st.session_state.settings = {}
     print(f"Warning: Failed to load settings.json: {e}")

19-20: Watch out for concurrent write access to global variables.

If you anticipate multiple concurrent requests, consider using a lock or synchronization mechanism for global_active_sessions and last_heartbeat to avoid race conditions.


22-25: Increment logic is simple but may require synchronization.

Add a threading lock if concurrency becomes relevant:

 lock = threading.Lock()

 def increment_active_sessions():
     global global_active_sessions
+    with lock:
         global_active_sessions += 1

27-33: Ensure no race conditions when decrementing sessions.

If multiple requests close quickly, you could trigger a double decrement. A lock can prevent the session count from dropping below zero inadvertently.


34-36: Graceful shutdown considerations.

Depending on user expectations, you might allow a cleanup procedure instead of sending SIGTERM immediately, ensuring any pending tasks finish before stopping the server.


45-51: Blocking call in the POST request.

Using time.sleep(1) in a Tornado handler can impact responsiveness. Consider an async approach or removing the delay if it’s not strictly needed.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7183499 and 568732c.

📒 Files selected for processing (1)
  • app.py (2 hunks)
🧰 Additional context used
🪛 Ruff (0.8.2)
app.py

11-11: pyopenms imported but unused

Remove unused import: pyopenms

(F401)

🔇 Additional comments (11)
app.py (11)

2-2: No concerns with using streamlit.components.v1.


5-9: Imports appear correct for new functionality.


13-13: No issues with the comment line.


38-42: Session registration could overcount if a user opens multiple tabs.

Confirm whether each new tab should represent a unique session. If not, consider using a dedicated session ID to track real user sessions accurately.


52-58: Heartbeat handler logic looks straightforward.


59-67: Potential port conflict when listening on 8501.

If Streamlit already binds to 8501, confirm that Tornado can coexist. Otherwise, use a separate port (e.g., 8502) or an unused one to avoid conflicts.


68-68: Running the Tornado server in a daemon thread is acceptable.


70-81: Heartbeat monitor is implemented correctly.

Though some tasks might require a lock around the last_heartbeat check, the current approach is sufficient for lightweight usage.


84-100: Regular heartbeat injection looks solid.


101-112: Close-tab script is an effective fallback.


139-144: Injection order of scripts after pg.run() is valid.

@Arslan-Siraj
Copy link
Contributor

@Ayushmaan06 thanks for looking into it.
Is this solution accomodate long runner processes?
and make sure its only work when we launched app as local.

import time
import threading
import signal
import tornado.web
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

app.py Outdated
(r"/heartbeat", HeartbeatHandler)
]
app = tornado.web.Application(routes)
app.listen(port=8501) # Adjust if needed; matches Streamlit port.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

app.py Outdated
pg.run()

# Inject the heartbeat script first so the server sees regular pings.
insert_heartbeat_script()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make sure for now just effect local.

app.py Outdated

def increment_active_sessions():
global global_active_sessions
global_active_sessions += 1
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have no idea of how python does this, but in C++ this would be a race condition.
Not sure if this is even multithreaded code but Copilot suggests

import threading

# --- Global Session Counter with Lock ---
global_active_sessions = 0
lock = threading.Lock()

def increment_active_sessions():
    global global_active_sessions
    with lock:
        global_active_sessions += 1

etc... (for all mentions of the global variable)

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (3)
app.py (3)

18-21: ⚠️ Potential issue

Address potential race conditions with global variables.

The global variables for session counting and heartbeat tracking are accessed from multiple threads without synchronization, which could lead to race conditions.

Implement thread synchronization using locks:

# --- Global Session Counter ---
global_active_sessions = 0
last_heartbeat = 0   # Global heartbeat timestamp
+session_lock = threading.Lock()  # Lock for thread safety

27-30: ⚠️ Potential issue

Make increment_active_sessions thread-safe.

This function directly modifies the global counter without synchronization, which could cause inaccurate counting in a multithreaded environment.

Update to use thread synchronization:

def increment_active_sessions():
    global global_active_sessions
-    global_active_sessions += 1
+    with session_lock:
+        global_active_sessions += 1
    print(f"Session connected, total active sessions: {global_active_sessions}")

32-37: ⚠️ Potential issue

Make decrement_active_sessions thread-safe.

This function also directly modifies the global counter without synchronization and makes a critical decision to shut down based on the counter value.

Update to use thread synchronization:

def decrement_active_sessions():
    global global_active_sessions
-    global_active_sessions -= 1
-    print(f"Session disconnected, total active sessions: {global_active_sessions}")
-    if global_active_sessions <= 0:
+    with session_lock:
+        global_active_sessions -= 1
+        current_count = global_active_sessions
+    print(f"Session disconnected, total active sessions: {current_count}")
+    if current_count <= 0:
        shutdown()
🧹 Nitpick comments (4)
requirements.txt (1)

11-11: Dependency addition looks good.

The addition of Tornado is appropriate for implementing the heartbeat and close app endpoints needed for the auto-shutdown functionality.

Consider pinning the version (e.g., tornado==6.2.0) for better reproducibility of builds, especially since other dependencies are pinned.

app.py (3)

11-11: Clarify the reason for the unused import.

The pyopenms import is flagged as unused by the static analyzer, but there's a comment stating it's required for Windows.

Improve the comment and suppress the linter warning:

-import pyopenms  # required import for Windows
+import pyopenms  # noqa: F401 - Required import for Windows to initialize OpenMS library
🧰 Tools
🪛 Ruff (0.8.2)

11-11: pyopenms imported but unused

Remove unused import: pyopenms

(F401)


50-56: Add request validation to prevent unauthorized shutdowns.

The current implementation allows any client to trigger the closeapp endpoint, which could lead to denial of service if exploited.

Add basic validation to ensure requests are legitimate:

class CloseAppHandler(tornado.web.RequestHandler):
    def post(self):
+        # Validate the request comes from the expected origin
+        origin = self.request.headers.get('Origin', '')
+        if not origin.startswith('http://localhost:') and not origin.startswith('https://localhost:'):
+            self.set_status(403)
+            self.write("Forbidden")
+            return
        time.sleep(1)
        decrement_active_sessions()
        self.write("OK")

36-41: Improve shutdown logging and add safety mechanism for session counter.

The current implementation has minimal logging and no recovery mechanism if the session counter gets out of sync.

Enhance the shutdown function and add a recovery mechanism:

def decrement_active_sessions():
    global global_active_sessions
-    global_active_sessions -= 1
-    print(f"Session disconnected, total active sessions: {global_active_sessions}")
-    if global_active_sessions <= 0:
+    with session_lock:
+        global_active_sessions = max(0, global_active_sessions - 1)  # Prevent negative values
+        current_count = global_active_sessions
+    print(f"Session disconnected, total active sessions: {current_count}")
+    if current_count <= 0:
        shutdown()

def shutdown():
-    print("🚨 No active users. Shutting down Streamlit server...")
+    import logging
+    logging.warning("🚨 No active users. Shutting down Streamlit server...")
+    print("🚨 No active users. Shutting down Streamlit server...")
    os.kill(os.getpid(), signal.SIGTERM)
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 568732c and ddb4b2e.

📒 Files selected for processing (3)
  • .env (1 hunks)
  • app.py (2 hunks)
  • requirements.txt (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • .env
🧰 Additional context used
🪛 Ruff (0.8.2)
app.py

11-11: pyopenms imported but unused

Remove unused import: pyopenms

(F401)

🔇 Additional comments (2)
app.py (2)

64-71: Verify Tornado port configuration and add error handling.

The Tornado server is configured to listen on port 8502, but there's no error handling if the port is already in use.

Ensure the port matches what's in the Streamlit .toml file and add error handling:

def start_tornado_server():
-    routes = [
-        (r"/_closeapp", CloseAppHandler),
-        (r"/heartbeat", HeartbeatHandler)
-    ]
-    app = tornado.web.Application(routes)
-    app.listen(port=8502)  # Adjust if needed; matches Streamlit port.
-    tornado.ioloop.IOLoop.current().start()
+    try:
+        routes = [
+            (r"/_closeapp", CloseAppHandler),
+            (r"/heartbeat", HeartbeatHandler)
+        ]
+        app = tornado.web.Application(routes)
+        app.listen(port=8502)  # Adjust if needed; matches Streamlit port.
+        print("Tornado server started on port 8502")
+        tornado.ioloop.IOLoop.current().start()
+    except OSError as e:
+        print(f"Error starting Tornado server: {e}")

Run this script to verify the port configuration in .streamlit/config.toml:

#!/bin/bash
# Check if port 8502 is defined in .streamlit/config.toml
if [ -f .streamlit/config.toml ]; then
  echo "Checking Streamlit port configuration..."
  if grep -q "port = 8502" .streamlit/config.toml; then
    echo "✓ Port 8502 is correctly configured in .streamlit/config.toml"
  else
    echo "Port configured in .streamlit/config.toml:"
    grep "port" .streamlit/config.toml || echo "No port configuration found"
  fi
else
  echo "No .streamlit/config.toml file found"
fi

133-135: Good use of environment variable to control local-only functionality.

The conditional activation based on the LOCAL_RUN environment variable correctly addresses the requirement to have this functionality only apply to local executions.

@Ayushmaan06
Copy link
Author

@Ayushmaan06 thanks for looking into it. Is this solution accomodate long runner processes? and make sure its only work when we launched app as local.

Yes, the solution can accommodate long‐running processes. The heartbeat monitor ensures the app stays active as long as it receives regular pings, and if no heartbeat is detected within the timeout interval, it automatically shuts down. Moreover, the auto‑shutdown features (heartbeat pings and close‑tab beacon) are only activated if the app is run locally (controlled via the LOCAL_RUN environment variable), ensuring they don't interfere with production or other long‑running environments.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
app.py (1)

87-93: ⚠️ Potential issue

Honor the “no sessions active” condition before shutdown.
Despite the comment “If no heartbeat received in 15 seconds and no sessions are active, shutdown,” the code ignores active. Update the condition to avoid forcing shutdown when sessions remain.

- if elapsed > 15:
-     shutdown()
+ if elapsed > 15 and active <= 0:
+     shutdown()
🧰 Tools
🪛 Ruff (0.8.2)

91-91: Local variable active is assigned to but never used

Remove assignment to unused variable active

(F841)

🧹 Nitpick comments (3)
app.py (3)

11-11: Consider removing or justifying the unused pyopenms import.
Static analysis flags pyopenms as unused. If it's only required for side effects on Windows, please leave a clarifying comment. Otherwise, consider removing it to keep imports tidy.

- import pyopenms  # required import for Windows
+ # If truly needed for side effects on Windows, keep and comment why.
+ # Otherwise, remove:
+ # import pyopenms
🧰 Tools
🪛 Ruff (0.8.2)

11-11: pyopenms imported but unused

Remove unused import: pyopenms

(F401)


53-59: Question the one-second delay on browser unload.
Sleeping for one second (line 56) might not be necessary and can delay resource cleanup. Unless there's a specific reason, consider removing or reducing this delay.

def post(self):
-    time.sleep(1)
     decrement_active_sessions()
     self.write("OK")

100-105: Avoid hardcoding the port in the heartbeat script.
If an alternative port is used or changed in configuration, the fetch URL becomes invalid. Use window.location or a relative path for better flexibility.

function sendHeartbeat() {
-    fetch('http://localhost:8502/heartbeat', {method: 'POST'});
+    const baseUrl = window.location.protocol + '//' + window.location.host;
+    fetch(baseUrl + '/heartbeat', {method: 'POST'});
}
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ddb4b2e and 7b1e4e2.

📒 Files selected for processing (1)
  • app.py (2 hunks)
🧰 Additional context used
🪛 Ruff (0.8.2)
app.py

11-11: pyopenms imported but unused

Remove unused import: pyopenms

(F401)


91-91: Local variable active is assigned to but never used

Remove assignment to unused variable active

(F841)

Comment on lines +53 to +59
# This handler is called on browser unload (if it fires)
class CloseAppHandler(tornado.web.RequestHandler):
def post(self):
time.sleep(1)
decrement_active_sessions()
self.write("OK")

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add client-side usage of the /_closeapp endpoint.
The CloseAppHandler endpoint is defined but never invoked by the client script. Implementing a beforeunload or unload listener with navigator.sendBeacon('/_closeapp') ensures the server receives disconnection signals reliably.

 <script>
   function sendHeartbeat() {
       fetch('http://localhost:8502/heartbeat', {method: 'POST'});
   }
   // ...
+  window.addEventListener('beforeunload', () => {
+      navigator.sendBeacon('http://localhost:8502/_closeapp');
+  });
 </script>

Also applies to: 70-71

@jcharkow
Copy link
Contributor

@singjc where we able to implement something like this in massdash?

@singjc
Copy link
Contributor

singjc commented Mar 12, 2025

@singjc where we able to implement something like this in massdash?

We don't have an auto shutdown, but we have a button that the user can click to terminate the process and close the browser tab that contains the app. https://github.com/Roestlab/massdash/blob/5cb2af88ec79d873ab5bc0e790cbc209af6a41a7/massdash/util.py#L471-L500

@Arslan-Siraj
Copy link
Contributor

@Ayushmaan06 thanks for looking into it. I run localy, and app is automatically closed even there is active tabs (open in browser)? can you look into it.
also see if massdash solution https://github.com/Roestlab/massdash/blob/5cb2af88ec79d873ab5bc0e790cbc209af6a41a7/massdash/util.py#L471-L500 work for us.

@Ayushmaan06
Copy link
Author

Ayushmaan06 commented Mar 13, 2025

@Ayushmaan06 thanks for looking into it. I run localy, and app is automatically closed even there is active tabs (open in browser)? can you look into it. also see if massdash solution https://github.com/Roestlab/massdash/blob/5cb2af88ec79d873ab5bc0e790cbc209af6a41a7/massdash/util.py#L471-L500 work for us.

The app closes automatically because my current logic checks if no active Streamlit tabs (i.e., no heartbeat signals) are present for 15 seconds, and if none are found, it calls “shutdown()” to terminate the process. That’s why the app stops even when a local tab might be open (if it isn’t sending heartbeats).

The MassDash approach uses a more explicit “Close” button that manually triggers the shutdown, including attempts to close the browser tab via system keystrokes. If we prefer that behavior instead of an automatic timeout, we can adapt MassDash’s function to our code by adding a “Close” button in the Streamlit UI. Then, when a user clicks it, the app would close the tab (if permissions allow) and terminate the process, rather than waiting on the 15-second timer.

@Arslan-Siraj Please, Let me know if you want me to integrate the MassDash flow or keep our current automatic shutdown!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Windows executable developed with wix: Closing the web browser does not terminate the streamlit process

5 participants