33import os
44import shutil
55import tempfile
6+ import signal
7+ import sys
68import time
79from pathlib import Path
810from typing import Any , Literal , Optional
@@ -45,6 +47,9 @@ class Stagehand:
4547
4648 # Dictionary to store one lock per session_id
4749 _session_locks = {}
50+
51+ # Flag to track if cleanup has been called
52+ _cleanup_called = False
4853
4954 def __init__ (
5055 self ,
@@ -188,6 +193,9 @@ def __init__(
188193 raise ValueError (
189194 "browserbase_project_id is required for BROWSERBASE env with existing session_id (or set BROWSERBASE_PROJECT_ID in env)."
190195 )
196+
197+ # Register signal handlers for graceful shutdown
198+ self ._register_signal_handlers ()
191199
192200 self ._client : Optional [httpx .AsyncClient ] = (
193201 None # Used for server communication in BROWSERBASE
@@ -214,6 +222,61 @@ def __init__(
214222 ** self .model_client_options ,
215223 )
216224
225+ def _register_signal_handlers (self ):
226+ """Register signal handlers for SIGINT and SIGTERM to ensure proper cleanup."""
227+ def cleanup_handler (sig , frame ):
228+ # Prevent multiple cleanup calls
229+ if self .__class__ ._cleanup_called :
230+ return
231+
232+ self .__class__ ._cleanup_called = True
233+ print (f"\n [{ signal .Signals (sig ).name } ] received. Ending Browserbase session..." )
234+
235+ try :
236+ # Try to get the current event loop
237+ try :
238+ loop = asyncio .get_running_loop ()
239+ except RuntimeError :
240+ # No event loop running - create one to run cleanup
241+ print ("No event loop running, creating one for cleanup..." )
242+ try :
243+ asyncio .run (self ._async_cleanup ())
244+ except Exception as e :
245+ print (f"Error during cleanup: { str (e )} " )
246+ finally :
247+ sys .exit (0 )
248+ return
249+
250+ # Schedule cleanup in the existing event loop
251+ # Use call_soon_threadsafe since signal handlers run in a different thread context
252+ def schedule_cleanup ():
253+ task = asyncio .create_task (self ._async_cleanup ())
254+ # Shield the task to prevent it from being cancelled
255+ shielded = asyncio .shield (task )
256+ # We don't need to await here since we're in call_soon_threadsafe
257+
258+ loop .call_soon_threadsafe (schedule_cleanup )
259+
260+ except Exception as e :
261+ print (f"Error during signal cleanup: { str (e )} " )
262+ sys .exit (1 )
263+
264+ # Register signal handlers
265+ signal .signal (signal .SIGINT , cleanup_handler )
266+ signal .signal (signal .SIGTERM , cleanup_handler )
267+
268+ async def _async_cleanup (self ):
269+ """Async cleanup method called from signal handler."""
270+ try :
271+ await self .close ()
272+ print (f"Session { self .session_id } ended successfully" )
273+ except Exception as e :
274+ print (f"Error ending Browserbase session: { str (e )} " )
275+ finally :
276+ # Force exit after cleanup completes (or fails)
277+ # Use os._exit to avoid any further Python cleanup that might hang
278+ os ._exit (0 )
279+
217280 def start_inference_timer (self ):
218281 """Start timer for tracking inference time."""
219282 self ._inference_start_time = time .time ()
@@ -628,15 +691,12 @@ async def close(self):
628691 self .logger .debug (
629692 f"Attempting to end server session { self .session_id } ..."
630693 )
631- # Use internal client if httpx_client wasn't provided externally
632- client_to_use = (
633- self ._client if not self .httpx_client else self .httpx_client
694+ # Don't use async with here as it might close the client prematurely
695+ # The _execute method will handle the request properly
696+ result = await self ._execute ("end" , {"sessionId" : self .session_id })
697+ self .logger .debug (
698+ f"Server session { self .session_id } ended successfully with result: { result } "
634699 )
635- async with client_to_use : # Ensure client context is managed
636- await self ._execute ("end" , {"sessionId" : self .session_id })
637- self .logger .debug (
638- f"Server session { self .session_id } ended successfully"
639- )
640700 except Exception as e :
641701 # Log error but continue cleanup
642702 self .logger .error (
@@ -685,6 +745,7 @@ async def close(self):
685745 self .logger .error (f"Error stopping Playwright: { str (e )} " )
686746
687747 self ._closed = True
748+ self .logger .debug ("All resources closed successfully" )
688749
689750 async def _create_session (self ):
690751 """
0 commit comments