5

I have a route / which started an endless loop (technically until the websocket is disconnected but in this simplified example it is truly endless). How do I stop this loop on shutdown:

from fastapi import FastAPI

import asyncio

app = FastAPI()
running = True

@app.on_event("shutdown")
def shutdown_event():
    global running
    running = False

@app.get("/")
async def index():
    while running:
        await asyncio.sleep(0.1)

According to the docs @app.on_event("shutdown") should be called during the shutdown, but is suspect it is called similar to the lifetime event which is called after everything is finished which is a deadlock in this situation.

To test:

  1. i run it as uvicorn module.filename:app --host 0.0.0.0
  2. curl http://ip:port/
  3. then stop the server (pressing CTRL+C)

and you see that it hangs forever since running is never set to false because shutdown_event is not called. (Yes you can force shutdown by pressing CTRL+C)

Sir l33tname
  • 4,026
  • 6
  • 38
  • 49
  • good point i added more infos to the questions, yes i stop it via `CTRL+C` – Sir l33tname Apr 10 '23 at 14:28
  • There is a way to force exit programatically (after pressing `CTRL+C` once), but the client would receive an `Internal Server Error` response. Is that what you would like to do? Or, are you looking for a graceful shutdown, allowing all running tasks/futures to safely complete before shutting down? – Chris Apr 10 '23 at 17:26
  • I'm not overly concerned about forcing the shutdown programatically, i would prefere a way to cleanly and orderly shutdown but if it crashes the entire thing after the first `CNTR+C` thats better than not stopping – Sir l33tname Apr 10 '23 at 18:36
  • The answer you accepted in the [github issue](https://github.com/tiangolo/fastapi/discussions/9373) is very similar to the one I was talking about earlier. However, as [mentioned by @Kludex](https://github.com/tiangolo/fastapi/discussions/9373#discussioncomment-5577845), using that approach, the server doesn't shut down cleanly and that is why I haven't posted it here yet, as that approach actually **forces** the app to exit (similar to pressing `CTRL+C` twice). I have been trying to find a way to shutdown the server gracefully instead. – Chris Apr 11 '23 at 08:23
  • Plus the answer provided on github does not even exit the app, but lets it hanging there, as the poster of that comment missed executing `sys.exit()` inside the signal handler. If you do so, you would see that the app is forced to exit, and the client receives `Internal Server Error` response – Chris Apr 11 '23 at 08:28
  • @chris i mean yes if you do a sys.exit() then its a 'problem' but the solution on github is cooperative and just ensures that the endless loop stops on shutdown which is exactly what i want and i dont see how this kills other request also if you test the example you see the client gets null and not an error – Sir l33tname Apr 11 '23 at 11:49
  • A `null` response was returned simply because `running` was set to `False` and `sys.exit()` was not called, otherwise the client would receive `Internal Server Error` response. You asked how to stop the loop on **shutdown**. So, using that approach, did your app actually shut down when pressing `CTRL+C`? I guess not, as the app in the console is still running. Hence, you need to call `sys.exit()`, and if you do so, as described earlier, the app will be forced to exit, without giving time to already running tasks in the event loop to complete first. – Chris Apr 11 '23 at 12:14
  • Ah I see this solution works for me i can stop the loop cooperatively, but it would be a problem if the sleep would be way longer as this would not be aborted but the solution described in the github issue solves my specific problem – Sir l33tname Apr 11 '23 at 13:22
  • Can you please clarify whether or not the app actually terminates after pressing `CTRL+C` using that approach (as well as what Python version and OS are you using)? I don't mean just stopping the loop, but actually exiting the app. – Chris Apr 11 '23 at 16:06
  • yes works for me on fedora since it stoppes the loop and then fast api terminates as normal since all requests are done, where does this example not work for you? – Sir l33tname Apr 11 '23 at 17:37

2 Answers2

0

I thought this would be a simple thing, but not :-) I think its even worth a feature request on FastAPI for a "pre-shutdown" event, because it could be simple if embedded in the code there.

So, when running, uvicorn registers a callback with the event loop to execute when requested to exit. That changes a state in the uvicorn Server object when called once (it sets the server.should_exit attribute to True). So, if you have a clean way to get the server instance running, you could just poll that attribute in your long-view to see whether it should exit. I found no way to get the a reference to the running server.

So I settled to register another signal handler: one you can have in your app in order to change states as needed. The problem with that: asyncio can only have one handler per signal, when one registers a handler, the previous one is lost. That means installing a custom handler would remove uvicorn's handlers, and it simply would not shut down at all.

To workaround that, I had to introspect the loop._signal_handlers in the running asyncio loop: this is supposed to be private, but by doing so, I could chain the original signal handler call after a custom signal-handler.

Long story short, this code works to exit the server on the first "ctrl + C":

from fastapi import FastAPI, Request
import asyncio

from uvicorn.server import HANDLED_SIGNALS
from functools import partial

app = FastAPI()
running = True

#@app.on_event("shutdown")
#def shutdown_event():
    #global running
    #running = False

@app.get("/")
async def index(request: Request):
    while running:
        await asyncio.sleep(0.1)

@app.on_event("startup")
def chain_signals():
    loop = asyncio.get_running_loop()
    loop = asyncio.get_running_loop()
    signal_handlers = getattr(loop, "_signal_handlers", {})  # disclaimer 1: this is a private attribute: might change without notice.
                                                            # Also: unix only, won't work on windows
    for sig in HANDLED_SIGNALS:
        loop.add_signal_handler(sig, partial(handle_exit, signal_handlers.get(sig, None))  , sig, None)

def handle_exit(original_handler, sig, frame):
    global running
    running = False
    if original_handler:
        return original_handler._run()   # disclaimer 2: this should be opaque and performed only by the running loop. 
                                         # not so bad: this is not changing, and is safe to do. 


I'd like to emphasize I was only able to get to this working snippet because you did provide a minimal working example of your problem. You'd get surprised how many question authors do not do so.

jsbueno
  • 99,910
  • 10
  • 151
  • 209
  • I am afraid this is not a multiplatform solution and should be made clear in the answer's description (not in a code comment). As described in the example given in [Python's documentation](https://docs.python.org/3/library/asyncio-eventloop.html#set-signal-handlers-for-sigint-and-sigterm), registering handlers for signals using the `loop.add_signal_handler()` works **only** on Unix. – Chris Apr 10 '23 at 17:13
  • 1
    I created an issue since it would be neat to have a solution which does not require internals :) https://github.com/tiangolo/fastapi/discussions/9373, I tested your solution and for me this terminates the loop but it seems to me it never actually shutdowns the server itself it hangs until i kill the process manually – Sir l33tname Apr 10 '23 at 18:34
  • For windows, one canjust ignore the asyncio add_signal_handler and add a handler directly with `signal.signal`. That is what FastAPI does internally as well. – jsbueno Apr 11 '23 at 21:07
  • On Windows, using `signal.signal()` function to define a custom handler would require to run `sys.exit()` inside it, in order to terminate the app; otherwise, the app will keep running (after pressing `CTRL+C`). This is what actually happens when running (on Windows) the example recently posted by the asker. OP mentioned that, on Fedora, the app exits; regardless, that example does not provide a graceful shutdown, but rather a hard shutdown (i.e., forces the app to exit), meaning that any pending tasks (e.g., requests and background tasks) are not given time to complete before exiting. – Chris Apr 12 '23 at 05:38
  • This is why one would need to add an `async` handler instead, where the `loop` can be stopped and time can be given to pending tasks to complete - that is the problem I've been trying to solve the last couple of days. And btw, `signal.signal()` is not used internally by FastAPI, but Uvicorn instead (see [here](https://github.com/encode/uvicorn/blob/016dee62e700be1ca05b2044e3d228a4c9d91bfc/uvicorn/server.py#L307)). – Chris Apr 12 '23 at 05:40
  • The original FastAPI installed handlers will take care of not exiting, and then exiting on the second try. They just have to be chained after the user installed handler, as I did in my example for a loop-tied handler. This, however, is no book chapter or production code, and the OP does not seen to be using windows: if one thinks a working windows example is due here, feel free to add it. (I would not have how to assert it works, anyway) – jsbueno Apr 12 '23 at 12:52
0
import signal
import asyncio
from fastapi import FastAPI

app = FastAPI()
running = True

def stop_server(*args):
    global running
    running = False

@app.on_event("startup")
def startup_event():
    signal.signal(signal.SIGINT, stop_server)

@app.get("/")
async def index():
    while running:
        await asyncio.sleep(0.1)

Source: https://github.com/tiangolo/fastapi/discussions/9373#discussioncomment-5573492

Setting up and catching the SIGINT signal allows to catch the first CNTR+C. This will set running to False which ends the loop in index(). Terminating the running request allowing the server to shutdown.

Sir l33tname
  • 4,026
  • 6
  • 38
  • 49