Skip to content

Shut down gracefully, and clean up when starting up fails - #499

Open
digitalresistor wants to merge 2 commits into
mainfrom
bugfix/graceful-shutdown
Open

Shut down gracefully, and clean up when starting up fails#499
digitalresistor wants to merge 2 commits into
mainfrom
bugfix/graceful-shutdown

Conversation

@digitalresistor

@digitalresistor digitalresistor commented Aug 2, 2026

Copy link
Copy Markdown
Member

Stopping a waitress server drops whatever is in flight on the floor. The main loop is torn down the moment the interrupt arrives, but the task threads hand their output back to that loop and use the trigger to wake it up — so anything not already written to the socket is lost, and a task thread parked on outbuf_lock waiting for the loop to drain a buffer never wakes up again.

Interrupting a 4MB response on main:

received 327356 of 4194447 bytes                                        <- truncated
1 thread(s) still running                                               <- wedged forever
ResourceWarning: unclosed file <waitress.wasyncore.file_wrapper ...>    <- the trigger's pipe
ResourceWarning: unclosed <socket.socket fd=6, ...>                     <- listening socket
ResourceWarning: unclosed <socket.socket fd=7, ...>                     <- client socket

Same thing on this branch: full 4,194,448 bytes, stderr clean, and it exits faster because nothing is left wedged.

This started as a fix for #480 so that #490 could land, and grew to cover the shutdown path it exposed.

Graceful shutdown

Follows the steps from #269 (comment) and @mmerickel's list in #198:

  1. close the listening sockets, so the port is released and no new connections arrive;
  2. stop reading new requests off the channels that are still open (new HTTPChannel.draining);
  3. keep running the main loop so requests already being serviced get answered;
  4. close the connections as they finish;
  5. only then stop the worker threads and tear down the trigger.

Exposed as server.graceful_shutdown(). run() uses it on SIGINT/SystemExit, and a second interrupt during the drain gives up immediately, as discussed with @viktordick in #198.

Closes #198, closes #264.

shutdown_timeoutcloses #134

New adjustment, default 5, 0 to tear everything down immediately. Bounds the drain, and replaces the timeout that was hardcoded in ThreadedTaskDispatcher.shutdown(). Wired through waitress-serve and documented.

start() / stop()closes #290

server = create_server(app, host="127.0.0.1", port=0)
server.start()
url = f"http://{server.effective_host}:{server.effective_port}/"
...
server.stop()

stop() signals the main loop through the trigger instead of closing things underneath it from another thread, so it is safe to call from anywhere — including from within the WSGI application, where it deliberately does not block (waiting there would deadlock on the request making the call). This is what WebTest's StopableWSGIServer has to work around today, and binding to port 0 and reading back effective_port avoids the free-port race @evandrocoan raised in #290.

Cleanup on startup failure — closes #480, closes #402

  • Worker threads are no longer started until every socket has been bound, so a failed bind doesn't strand a ThreadedTaskDispatcher nobody has a reference to.
  • A listener that bound successfully is now closed if a later one fails, instead of being left in the socket map.
  • BaseWSGIServer.__init__ cleans up after itself — trigger, socket, map entry, dispatcher.
  • BaseWSGIServer.close() shuts down the task dispatcher and closes the connections that are still open, matching MultiSocketServer.close(). An ownership flag keeps one listener closing from killing a MultiSocketServer's shared thread pool.

With #480 fixed, the socket leak that #490 ran into is gone — its test_port_bind_failure_logging no longer leaves anything behind. I left the bind logging itself out of this PR so it can land on its own.

Also

pull_trigger() is a no-op once the trigger is closed. A task thread finishing as the server went away could previously write to a closed file descriptor — one that may well have been handed out to something else by then.

Notes for review

  • Tests: 822 passing locally, 100% coverage, black/isort/sphinx -W clean. The new coverage includes end-to-end tests over real sockets that assert a large in-flight response survives shutdown intact. CI is green across the full matrix; the 29 new tests run everywhere except the two bind-failure ones, which are skipped on Windows because SO_REUSEADDR there lets you rebind a port that is already being listened on.
  • shutdown_timeout is marked .. versionadded:: 3.1.0. pyproject.toml is deliberately left at 3.0.2 and CHANGES.txt at Unreleased, since this repo bumps the version as a separate release step.
  • Keeping this as a single commit rather than splitting it per issue.

Stopping a waitress server dropped whatever was in flight on the floor.
The main loop was torn down the moment the interrupt arrived, but the
task threads hand their output back to that loop and use the trigger to
wake it up, so anything that had not already been written to the socket
was lost. A task thread parked on outbuf_lock waiting for the loop to
drain a buffer never woke up again either, and the trigger's pipe and
the listening socket were left dangling.

Interrupting a 4MB response used to look like this:

    received 327356 of 4194447 bytes
    1 thread(s) still running
    ResourceWarning: unclosed file <waitress.wasyncore.file_wrapper ...>
    ResourceWarning: unclosed <socket.socket fd=6, ...>
    ResourceWarning: unclosed <socket.socket fd=7, ...>

Shutting down now follows the steps laid out in #269: close the
listening sockets, stop reading new requests off the channels that are
still open, keep running the main loop until the requests that are
already being serviced have been answered, and only then stop the
worker threads and close the trigger. How long that may take is bounded
by a new shutdown_timeout adjustment, which also replaces the timeout
that was hardcoded in ThreadedTaskDispatcher.shutdown().

This is available as server.graceful_shutdown(), and as start()/stop()
for running a server in a background thread. stop() signals the main
loop through the trigger rather than closing things underneath it from
another thread, so it is safe to call from anywhere, including from the
WSGI application. Together with effective_port that covers what
WebTest's StopableWSGIServer had to work around.

Separately, failing to create a server leaked. The worker threads were
started before the first socket was bound, and a listener that bound
successfully was never closed if a later one failed, leaving threads
and sockets running that the caller had no way to reach. The threads
are now started only once every socket is bound, and anything created
before a failure is cleaned up again. BaseWSGIServer.close() also stops
the task dispatcher and closes the connections that are still open,
which is what MultiSocketServer.close() already did.

Fixes #480
Fixes #402
Fixes #134
Fixes #264
Fixes #290
This release adds a new shutdown_timeout adjustment and the start()/
stop() API, so it is not going out as a 3.0.x. Pinning it down now
avoids the documented '.. versionadded:: 3.1.0' guessing at a version
that turns out to be a different one by the time it ships.
@digitalresistor
digitalresistor force-pushed the bugfix/graceful-shutdown branch from a66168d to 009ba20 Compare August 2, 2026 23:25

@kgaughan kgaughan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I see nothing blocking merging this, though I think the error message fix is worth adding.

Comment thread src/waitress/server.py
servers = []

try:
if adj.unix_socket and hasattr(socket, "AF_UNIX"):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is more something that bugged me about #215, and I know changing this would mean a change in behaviour so this PR probably isn't the place to deal with it, but shouldn't the path with Unix sockets when adj.unix_socket be mutually exclusive with adj.sockets not being empty, just like TCP sockets are? It'd simplify the logic a tad as a benefit to something more like this:

try:
    if not adj.sockets:
        if adj.unix_socket and hasattr(socket, "AF_UNIX"):
            ...
        for sockinfo in adj.listen:
            ...
    for sock in adj.sockets:
        ...

Comment thread src/waitress/server.py
Comment on lines +131 to +132
"There are no sockets to listen on, both 'listen' and 'sockets' "
"are empty."

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Missed unix_socket:

Suggested change
"There are no sockets to listen on, both 'listen' and 'sockets' "
"are empty."
"There are no sockets to listen on: 'unix_socket', 'listen', "
"and 'sockets' are all empty."

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