From 3e2abf9dbe9a6c1e7e9792dd75f80860922f6994 Mon Sep 17 00:00:00 2001 From: art Date: Thu, 8 Oct 2026 22:59:10 +0500 Subject: [PATCH] Gracefully drain workers on shutdown instead of terminating them --- CHANGELOG.rst | 6 ++++++ src/hypercorn/run.py | 16 ++++++++++++++-- tests/test_run.py | 17 +++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 tests/test_run.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3762f81b..e78d9f5e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,9 @@ +0.18.1 (unreleased) +----------------- + +* Bugfix the master process does not terminate workers still serving in-flight + requests when another worker exits on SIGTERM/SIGINT. + 0.18.0 2025-11-08 ----------------- diff --git a/src/hypercorn/run.py b/src/hypercorn/run.py index b83f4cab..dbbc535e 100644 --- a/src/hypercorn/run.py +++ b/src/hypercorn/run.py @@ -100,8 +100,7 @@ def shutdown(*args: Any) -> None: shutdown_event.set() active = False - for process in processes: - process.terminate() + _drain(processes) exitcode = _join_exited(processes) if exitcode != 0 else exitcode @@ -139,6 +138,19 @@ def _populate( time.sleep(0.1) +def _drain(processes: list[BaseProcess]) -> None: + """Wait for the workers to exit gracefully. + + On SIGTERM/SIGINT the master used to ``terminate()`` the workers still + processing requests as soon as an idle worker exited, cutting off + in-flight requests. Workers instead need to finish their own shutdown + (bounded on their side by ``config.graceful_timeout``), so the master + waits for them to exit on their own (see #386). + """ + for process in processes: + process.join() + + def _join_exited(processes: list[BaseProcess]) -> int: exitcode = 0 for index in reversed(range(len(processes))): diff --git a/tests/test_run.py b/tests/test_run.py new file mode 100644 index 00000000..b6b1e134 --- /dev/null +++ b/tests/test_run.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from unittest import mock + +from hypercorn.run import _drain + + +def test_drain_waits_for_workers_to_exit() -> None: + """The master must join the workers instead of terminating them, so + that in-flight requests are served on shutdown (see #386).""" + processes = [mock.Mock(), mock.Mock()] + + _drain(processes) + + for process in processes: + process.join.assert_called_once_with() + process.terminate.assert_not_called()