Skip to content

Commit f4c4079

Browse files
committed
Fixed automatic shutdown issue
1 parent e3e5719 commit f4c4079

File tree

4 files changed

+76
-44
lines changed

4 files changed

+76
-44
lines changed

locallab/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
LocalLab - A lightweight AI inference server for running LLMs locally
33
"""
44

5-
__version__ = "0.4.43"
5+
__version__ = "0.4.44"
66

77
# Only import what's necessary initially, lazy-load the rest
88
from .logger import get_logger

locallab/core/app.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -210,21 +210,25 @@ async def shutdown_event():
210210

211211
logger.info(f"{Fore.GREEN}Server shutdown complete{Style.RESET_ALL}")
212212

213-
# Force exit if needed to clean up any hanging resources
214-
import threading
215-
def force_exit():
216-
import time
217-
import os
218-
import signal
219-
time.sleep(3) # Give a little time for clean shutdown
220-
logger.info("Forcing exit after shutdown to ensure clean termination")
221-
try:
222-
os.kill(os.getpid(), signal.SIGTERM)
223-
except:
224-
os._exit(0)
225-
226-
threading.Thread(target=force_exit, daemon=True).start()
213+
# Only force exit if this is a true shutdown initiated by SIGINT/SIGTERM
214+
# Check if this was triggered by an actual signal
215+
if hasattr(shutdown_event, 'force_exit_required') and shutdown_event.force_exit_required:
216+
import threading
217+
def force_exit():
218+
import time
219+
import os
220+
import signal
221+
time.sleep(3) # Give a little time for clean shutdown
222+
logger.info("Forcing exit after shutdown to ensure clean termination")
223+
try:
224+
os._exit(0) # Direct exit instead of sending another signal
225+
except:
226+
pass
227+
228+
threading.Thread(target=force_exit, daemon=True).start()
227229

230+
# Initialize the flag (default to not forcing exit)
231+
shutdown_event.force_exit_required = False
228232

229233
@app.middleware("http")
230234
async def add_process_time_header(request: Request, call_next):

locallab/server.py

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -155,11 +155,22 @@ def check_environment() -> List[Tuple[str, str, bool]]:
155155
def signal_handler(signum, frame):
156156
print(f"\n{Fore.YELLOW}Received signal {signum}, shutting down server...{Style.RESET_ALL}")
157157

158+
# Check if we're already shutting down to avoid duplication
159+
if hasattr(signal_handler, 'shutting_down') and signal_handler.shutting_down:
160+
print("Already shutting down, please wait...")
161+
return
162+
163+
# Set flag to avoid duplicate shutdown
164+
signal_handler.shutting_down = True
165+
158166
set_server_status("shutting_down")
159167

160168
try:
161169
from .core.app import shutdown_event
162170

171+
# Mark that this is a real shutdown that needs a force exit
172+
shutdown_event.force_exit_required = True
173+
163174
loop = asyncio.get_event_loop()
164175
if not loop.is_closed():
165176
loop.create_task(shutdown_event())
@@ -185,14 +196,9 @@ def delayed_exit():
185196
# Start a daemonic thread to force exit after a timeout
186197
force_exit_thread = threading.Thread(target=delayed_exit, daemon=True)
187198
force_exit_thread.start()
188-
189-
# Signal the main thread to exit immediately
190-
# This is important to ensure that even if the server is stuck, the process will terminate
191-
logger.info("Sending SIGINT to the current process to ensure clean exit")
192-
time.sleep(1) # Give some time for the delayed_exit thread to start
193-
194-
# If we're still running after 1 second, try to force kill the process again
195-
os.kill(os.getpid(), signal.SIGTERM)
199+
200+
# Initialize the shutting down flag
201+
signal_handler.shutting_down = False
196202

197203

198204
class NoopLifespan:
@@ -499,15 +505,18 @@ def __init__(self, config):
499505

500506
def install_signal_handlers(self):
501507
def handle_exit(signum, frame):
508+
if hasattr(handle_exit, 'called') and handle_exit.called:
509+
return
510+
511+
handle_exit.called = True
502512
self.should_exit = True
503513
logger.debug(f"Signal {signum} received in ServerWithCallback, setting should_exit=True")
504514

505-
# Try to propagate the signal to parent process
506-
# This helps ensure the process exits properly
507-
try:
508-
os.kill(os.getpid(), signal.SIGTERM)
509-
except Exception:
510-
pass
515+
# Don't propagate signals back to avoid loops
516+
# The main signal_handler will handle process termination
517+
518+
# Initialize the flag
519+
handle_exit.called = False
511520

512521
signal.signal(signal.SIGINT, handle_exit)
513522
signal.signal(signal.SIGTERM, handle_exit)
@@ -694,6 +703,7 @@ def start_server(use_ngrok: bool = None, port: int = None, ngrok_auth_token: Opt
694703

695704
# Flag to track if startup has been completed
696705
startup_complete = [False] # Using a list as a mutable reference
706+
should_force_exit = [False] # To prevent premature shutdown
697707

698708
# Create a custom logging handler to detect when the application is ready
699709
class StartupDetectionHandler(logging.Handler):
@@ -704,11 +714,16 @@ def __init__(self, server_ref):
704714
def emit(self, record):
705715
if not startup_complete[0] and "Application startup complete" in record.getMessage():
706716
logger.info("Detected application startup complete message")
707-
if hasattr(self.server_ref, "handle_app_startup_complete"):
708-
self.server_ref.handle_app_startup_complete()
709-
else:
710-
logger.warning("Server reference doesn't have handle_app_startup_complete method")
711-
# Call on_startup directly as a fallback
717+
try:
718+
if hasattr(self.server_ref[0], "handle_app_startup_complete"):
719+
self.server_ref[0].handle_app_startup_complete()
720+
else:
721+
logger.warning("Server reference doesn't have handle_app_startup_complete method")
722+
# Call on_startup directly as a fallback
723+
on_startup()
724+
except Exception as e:
725+
logger.error(f"Error in startup detection handler: {e}")
726+
# Still try to display banners as a last resort
712727
on_startup()
713728

714729
def on_startup():
@@ -773,6 +788,18 @@ def on_startup():
773788
# Ensure server status is set to running even if display fails
774789
set_server_status("running")
775790

791+
# Define async callback that uvicorn can call
792+
async def on_startup_async():
793+
# This is an async callback that uvicorn might call
794+
if not startup_complete[0]:
795+
on_startup()
796+
797+
# Define this as a function to be called by uvicorn
798+
def callback_notify_function():
799+
# If needed, create and return an awaitable
800+
loop = asyncio.get_event_loop()
801+
return loop.create_task(on_startup_async())
802+
776803
try:
777804
# Detect if we're in Google Colab
778805
in_colab = is_in_colab()
@@ -795,19 +822,13 @@ def on_startup():
795822

796823
logger.info(f"Starting server on port {port} (Colab/ngrok mode)")
797824

798-
# Define the callback for Colab
799-
async def on_startup_async():
800-
# This is an async callback that uvicorn might call
801-
if not startup_complete[0]:
802-
on_startup()
803-
804825
config = uvicorn.Config(
805826
app,
806827
host="0.0.0.0", # Bind to all interfaces in Colab
807828
port=port,
808829
reload=False,
809830
log_level="info",
810-
callback_notify=[on_startup_async] # Use a list for the callback
831+
callback_notify=callback_notify_function # Use a function, not a list
811832
)
812833

813834
server = ServerWithCallback(config)
@@ -861,7 +882,7 @@ async def on_startup_async():
861882
reload=False,
862883
workers=1,
863884
log_level="info",
864-
callback_notify=[lambda: on_startup()] # Use a lambda to prevent immediate execution
885+
callback_notify=callback_notify_function # Use a function, not a lambda or list
865886
)
866887

867888
server = ServerWithCallback(config)
@@ -910,6 +931,12 @@ async def on_startup_async():
910931
on_startup()
911932

912933
except Exception as e:
934+
# Don't handle TypeError about 'list' object not being callable - that's exactly what we're fixing
935+
if "'list' object is not callable" in str(e):
936+
logger.error("Server error: callback_notify was passed a list instead of a callable function.")
937+
logger.error("This is a known issue that will be fixed in the next version.")
938+
raise
939+
913940
logger.error(f"Server startup failed: {str(e)}")
914941
logger.error(traceback.format_exc())
915942
set_server_status("error")
@@ -923,7 +950,8 @@ async def on_startup_async():
923950
app="locallab.core.minimal:app", # Use a minimal app if available, or create one
924951
host="127.0.0.1",
925952
port=port or 8000,
926-
log_level="info"
953+
log_level="info",
954+
callback_notify=None # Don't use callbacks in the minimal server
927955
)
928956

929957
# Create a simple server

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
setup(
77
name="locallab",
8-
version="0.4.43",
8+
version="0.4.44",
99
packages=find_packages(include=["locallab", "locallab.*"]),
1010
install_requires=[
1111
"fastapi>=0.95.0,<1.0.0",

0 commit comments

Comments
 (0)