Repository: sanic-org/sanic

Issue:
Cannot use Ctrl + C to exit the program normally
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Describe the bug

Use Ctrl + C to interrupt the program when the program startup is not complete, and subsequent Ctrl + C will fail.

The operation procedure and related logs are as follows:

1. Use Ctrl + C for the first time
[2024-07-16 13:04:06 +0800] [19752] [INFO] Received signal SIGINT. Shutting down.
Traceback (most recent call last):
  File "/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/multiprocessing/process.py", line 314, in _bootstrap
    self.run()
  File "/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/multiprocessing/process.py", line 108, in run
    self._target(*self._args, **self._kwargs)
  File "/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/site-packages/sanic/worker/serve.py", line 117, in worker_serve
    return _serve_http_1(
  File "/Library/Frameworks/Python.framework/Versions/3.10/lib/python3.10/site-packages/sanic/server/runners.py", line 257, in _serve_http_1
    loop.run_until_complete(app._server_event("init", "before"))
  File "uvloop/loop.pyx", line 1511, in uvloop.loop.Loop.run_until_complete
  File "uvloop/loop.pyx", line 1504, in uvloop.loop.Loop.run_until_complete
  File "uvloop/loop.pyx", line 1377, in uvloop.loop.Loop.run_forever
  File "uvloop/loop.pyx", line 555, in uvloop.loop.Loop._run
  File "uvloop/handles/poll.pyx", line 216, in uvloop.loop.__on_uvpoll_event
  File "uvloop/cbhandles.pyx", line 83, in uvloop.loop.Handle._run
  File "uvloop/cbhandles.pyx", line 66, in uvloop.loop.Handle._run
  File "uvloop/loop.pyx", line 397, in uvloop.loop.Loop._read_from_self
  File "uvloop/loop.pyx", line 402, in uvloop.loop.Loop._invoke_signals
  File "uvloop/loop.pyx", line 377, in uvloop.loop.Loop._ceval_process_signals
KeyboardInterrupt

2. Use Ctrl + C for the second or more time
[2024-07-16 13:04:08 +0800] [19752] [INFO] Shutdown interrupted. Killing.
[2024-07-16 13:04:08 +0800] [19752] [INFO] Killing Sanic-Server-0-0 [19757]
[2024-07-16 13:04:08 +0800] [19752] [INFO] Received signal SIGINT. Shutting down.


### Code snippet

```python
import asyncio

from sanic.app import Sanic

app = Sanic(name='test_sanic')


@app.listener('before_server_start')
async def before_server_start(app, loop):
    await asyncio.sleep(5)
```

### Expected Behavior

_No response_

### How do you run Sanic?

Sanic CLI

### Operating System

MacOS

### Sanic Version

23.12.2

### Additional context

_No response_

Discussion:

Patch:
diff --git a/sanic/worker/manager.py b/sanic/worker/manager.py
index 4c9487bc..02d11848 100644
--- a/sanic/worker/manager.py
+++ b/sanic/worker/manager.py
@@ -372,7 +372,10 @@ class WorkerManager:
         for process in self.processes:
             logger.info("Killing %s [%s]", process.name, process.pid)
             with suppress(ProcessLookupError):
-                os.kill(process.pid, SIGKILL)
+                try:
+                    os.killpg(os.getpgid(process.pid), SIGKILL)
+                except OSError:
+                    os.kill(process.pid, SIGKILL)
         raise ServerKilled
 
     def shutdown_signal(self, signal, frame):
@@ -381,6 +384,7 @@ class WorkerManager:
             logger.info("Shutdown interrupted. Killing.")
             with suppress(ServerKilled):
                 self.kill()
+            return
 
         logger.info("Received signal %s. Shutting down.", Signals(signal).name)
         self.monitor_publisher.send(None)


Test patch:
diff --git a/tests/test_reloader.py b/tests/test_reloader.py
index 8df46f64..3c478dab 100644
--- a/tests/test_reloader.py
+++ b/tests/test_reloader.py
@@ -1,243 +1,246 @@
-import os
-import secrets
-import sys
-
-from contextlib import suppress
-from subprocess import PIPE, Popen, TimeoutExpired
-from tempfile import TemporaryDirectory
-from textwrap import dedent
-from threading import Timer
-from time import sleep
+# 2024-12-22 AMH - Reloader tests have not been working for a while.
+# We need to re-implement them.
 
-import pytest
+# import os
+# import secrets
+# import sys
 
+# from contextlib import suppress
+# from subprocess import PIPE, Popen, TimeoutExpired
+# from tempfile import TemporaryDirectory
+# from textwrap import dedent
+# from threading import Timer
+# from time import sleep
 
-# We need to interrupt the autoreloader without killing it,
-# so that the server gets terminated
-# https://stefan.sofa-rockers.org/2013/08/15/handling-sub-process-hierarchies-python-linux-os-x/
+# import pytest
 
-try:
-    from signal import CTRL_BREAK_EVENT
-    from subprocess import CREATE_NEW_PROCESS_GROUP
 
-    flags = CREATE_NEW_PROCESS_GROUP
-except ImportError:
-    flags = 0
+# # We need to interrupt the autoreloader without killing it,
+# # so that the server gets terminated
+# # https://stefan.sofa-rockers.org/2013/08/15/handling-sub-process-hierarchies-python-linux-os-x/
 
-TIMER_DELAY = 2
+# try:
+#     from signal import CTRL_BREAK_EVENT
+#     from subprocess import CREATE_NEW_PROCESS_GROUP
 
+#     flags = CREATE_NEW_PROCESS_GROUP
+# except ImportError:
+#     flags = 0
 
-def terminate(proc):
-    if flags:
-        proc.send_signal(CTRL_BREAK_EVENT)
-    else:
-        proc.terminate()
+# TIMER_DELAY = 2
 
 
-def write_app(filename, **runargs):
-    text = secrets.token_urlsafe()
-    with open(filename, "w") as f:
-        f.write(
-            dedent(
-                f"""\
-            import os
-            from sanic import Sanic
-
-            app = Sanic(__name__)
+# def terminate(proc):
+#     if flags:
+#         proc.send_signal(CTRL_BREAK_EVENT)
+#     else:
+#         proc.terminate()
 
-            app.route("/")(lambda x: x)
-
-            @app.listener("after_server_start")
-            def complete(*args):
-                print("complete", os.getpid(), {text!r})
-
-            if __name__ == "__main__":
-                app.run(**{runargs!r})
-            """
-            )
-        )
-    return text
-
-
-def write_listener_app(filename, **runargs):
-    start_text = secrets.token_urlsafe()
-    stop_text = secrets.token_urlsafe()
-    with open(filename, "w") as f:
-        f.write(
-            dedent(
-                f"""\
-            import os
-            from sanic import Sanic
-
-            app = Sanic(__name__)
-
-            app.route("/")(lambda x: x)
-
-            @app.reload_process_start
-            async def reload_start(*_):
-                print("reload_start", os.getpid(), {start_text!r})
-
-            @app.reload_process_stop
-            async def reload_stop(*_):
-                print("reload_stop", os.getpid(), {stop_text!r})
-
-            if __name__ == "__main__":
-                app.run(**{runargs!r})
-            """
-            )
-        )
-    return start_text, stop_text
-
-
-def write_json_config_app(filename, jsonfile, **runargs):
-    with open(filename, "w") as f:
-        f.write(
-            dedent(
-                f"""\
-            import os
-            from sanic import Sanic
-            import json
-
-            app = Sanic(__name__)
-            with open("{jsonfile}", "r") as f:
-                config = json.load(f)
-            app.config.update_config(config)
-
-            app.route("/")(lambda x: x)
-
-            @app.listener("after_server_start")
-            def complete(*args):
-                print("complete", os.getpid(), app.config.FOO)
-
-            if __name__ == "__main__":
-                app.run(**{runargs!r})
-            """
-            )
-        )
-
-
-def write_file(filename):
-    text = secrets.token_urlsafe()
-    with open(filename, "w") as f:
-        f.write(f"""{{"FOO": "{text}"}}""")
-    return text
-
-
-def scanner(proc, trigger="complete"):
-    for line in proc.stdout:
-        line = line.decode().strip()
-        if line.startswith(trigger):
-            yield line
-
-
-argv = dict(
-    script=[sys.executable, "reloader.py"],
-    module=[sys.executable, "-m", "reloader"],
-    sanic=[
-        sys.executable,
-        "-m",
-        "sanic",
-        "--port",
-        "42204",
-        "--auto-reload",
-        "reloader.app",
-    ],
-)
-
-
-@pytest.mark.parametrize(
-    "runargs, mode",
-    [
-        (dict(port=42202, auto_reload=True), "script"),
-        (dict(port=42203, auto_reload=True), "module"),
-        ({}, "sanic"),
-    ],
-)
-@pytest.mark.xfail
-async def test_reloader_live(runargs, mode):
-    with TemporaryDirectory() as tmpdir:
-        filename = os.path.join(tmpdir, "reloader.py")
-        text = write_app(filename, **runargs)
-        command = argv[mode]
-        proc = Popen(command, cwd=tmpdir, stdout=PIPE, creationflags=flags)
-        try:
-            timeout = Timer(TIMER_DELAY, terminate, [proc])
-            timeout.start()
-            # Python apparently keeps using the old source sometimes if
-            # we don't sleep before rewrite (pycache timestamp problem?)
-            sleep(1)
-            line = scanner(proc)
-            assert text in next(line)
-            # Edit source code and try again
-            text = write_app(filename, **runargs)
-            assert text in next(line)
-        finally:
-            timeout.cancel()
-            terminate(proc)
-            with suppress(TimeoutExpired):
-                proc.wait(timeout=3)
-
-
-@pytest.mark.parametrize(
-    "runargs, mode",
-    [
-        (dict(port=42302, auto_reload=True), "script"),
-        (dict(port=42303, auto_reload=True), "module"),
-        ({}, "sanic"),
-    ],
-)
-@pytest.mark.xfail
-async def test_reloader_live_with_dir(runargs, mode):
-    with TemporaryDirectory() as tmpdir:
-        filename = os.path.join(tmpdir, "reloader.py")
-        config_file = os.path.join(tmpdir, "config.json")
-        runargs["reload_dir"] = tmpdir
-        write_json_config_app(filename, config_file, **runargs)
-        text = write_file(config_file)
-        command = argv[mode]
-        if mode == "sanic":
-            command += ["--reload-dir", tmpdir]
-        proc = Popen(command, cwd=tmpdir, stdout=PIPE, creationflags=flags)
-        try:
-            timeout = Timer(TIMER_DELAY, terminate, [proc])
-            timeout.start()
-            # Python apparently keeps using the old source sometimes if
-            # we don't sleep before rewrite (pycache timestamp problem?)
-            sleep(1)
-            line = scanner(proc)
-            assert text in next(line)
-            # Edit source code and try again
-            text = write_file(config_file)
-            assert text in next(line)
-        finally:
-            timeout.cancel()
-            terminate(proc)
-            with suppress(TimeoutExpired):
-                proc.wait(timeout=3)
-
-
-def test_reload_listeners():
-    with TemporaryDirectory() as tmpdir:
-        filename = os.path.join(tmpdir, "reloader.py")
-        start_text, stop_text = write_listener_app(
-            filename, port=42305, auto_reload=True
-        )
-
-        proc = Popen(
-            argv["script"], cwd=tmpdir, stdout=PIPE, creationflags=flags
-        )
-        try:
-            timeout = Timer(TIMER_DELAY, terminate, [proc])
-            timeout.start()
-            # Python apparently keeps using the old source sometimes if
-            # we don't sleep before rewrite (pycache timestamp problem?)
-            sleep(1)
-            line = scanner(proc, "reload_start")
-            assert start_text in next(line)
-            line = scanner(proc, "reload_stop")
-            assert stop_text in next(line)
-        finally:
-            timeout.cancel()
-            terminate(proc)
-            with suppress(TimeoutExpired):
-                proc.wait(timeout=3)
+
+# def write_app(filename, **runargs):
+#     text = secrets.token_urlsafe()
+#     with open(filename, "w") as f:
+#         f.write(
+#             dedent(
+#                 f"""\
+#             import os
+#             from sanic import Sanic
+
+#             app = Sanic(__name__)
+
+#             app.route("/")(lambda x: x)
+
+#             @app.listener("after_server_start")
+#             def complete(*args):
+#                 print("complete", os.getpid(), {text!r})
+
+#             if __name__ == "__main__":
+#                 app.run(**{runargs!r})
+#             """
+#             )
+#         )
+#     return text
+
+
+# def write_listener_app(filename, **runargs):
+#     start_text = secrets.token_urlsafe()
+#     stop_text = secrets.token_urlsafe()
+#     with open(filename, "w") as f:
+#         f.write(
+#             dedent(
+#                 f"""\
+#             import os
+#             from sanic import Sanic
+
+#             app = Sanic(__name__)
+
+#             app.route("/")(lambda x: x)
+
+#             @app.reload_process_start
+#             async def reload_start(*_):
+#                 print("reload_start", os.getpid(), {start_text!r})
+
+#             @app.reload_process_stop
+#             async def reload_stop(*_):
+#                 print("reload_stop", os.getpid(), {stop_text!r})
+
+#             if __name__ == "__main__":
+#                 app.run(**{runargs!r})
+#             """
+#             )
+#         )
+#     return start_text, stop_text
+
+
+# def write_json_config_app(filename, jsonfile, **runargs):
+#     with open(filename, "w") as f:
+#         f.write(
+#             dedent(
+#                 f"""\
+#             import os
+#             from sanic import Sanic
+#             import json
+
+#             app = Sanic(__name__)
+#             with open("{jsonfile}", "r") as f:
+#                 config = json.load(f)
+#             app.config.update_config(config)
+
+#             app.route("/")(lambda x: x)
+
+#             @app.listener("after_server_start")
+#             def complete(*args):
+#                 print("complete", os.getpid(), app.config.FOO)
+
+#             if __name__ == "__main__":
+#                 app.run(**{runargs!r})
+#             """
+#             )
+#         )
+
+
+# def write_file(filename):
+#     text = secrets.token_urlsafe()
+#     with open(filename, "w") as f:
+#         f.write(f"""{{"FOO": "{text}"}}""")
+#     return text
+
+
+# def scanner(proc, trigger="complete"):
+#     for line in proc.stdout:
+#         line = line.decode().strip()
+#         if line.startswith(trigger):
+#             yield line
+
+
+# argv = dict(
+#     script=[sys.executable, "reloader.py"],
+#     module=[sys.executable, "-m", "reloader"],
+#     sanic=[
+#         sys.executable,
+#         "-m",
+#         "sanic",
+#         "--port",
+#         "42204",
+#         "--auto-reload",
+#         "reloader.app",
+#     ],
+# )
+
+
+# @pytest.mark.parametrize(
+#     "runargs, mode",
+#     [
+#         (dict(port=42202, auto_reload=True), "script"),
+#         (dict(port=42203, auto_reload=True), "module"),
+#         ({}, "sanic"),
+#     ],
+# )
+# @pytest.mark.xfail
+# async def test_reloader_live(runargs, mode):
+#     with TemporaryDirectory() as tmpdir:
+#         filename = os.path.join(tmpdir, "reloader.py")
+#         text = write_app(filename, **runargs)
+#         command = argv[mode]
+#         proc = Popen(command, cwd=tmpdir, stdout=PIPE, creationflags=flags)
+#         try:
+#             timeout = Timer(TIMER_DELAY, terminate, [proc])
+#             timeout.start()
+#             # Python apparently keeps using the old source sometimes if
+#             # we don't sleep before rewrite (pycache timestamp problem?)
+#             sleep(1)
+#             line = scanner(proc)
+#             assert text in next(line)
+#             # Edit source code and try again
+#             text = write_app(filename, **runargs)
+#             assert text in next(line)
+#         finally:
+#             timeout.cancel()
+#             terminate(proc)
+#             with suppress(TimeoutExpired):
+#                 proc.wait(timeout=3)
+
+
+# @pytest.mark.parametrize(
+#     "runargs, mode",
+#     [
+#         (dict(port=42302, auto_reload=True), "script"),
+#         (dict(port=42303, auto_reload=True), "module"),
+#         ({}, "sanic"),
+#     ],
+# )
+# @pytest.mark.xfail
+# async def test_reloader_live_with_dir(runargs, mode):
+#     with TemporaryDirectory() as tmpdir:
+#         filename = os.path.join(tmpdir, "reloader.py")
+#         config_file = os.path.join(tmpdir, "config.json")
+#         runargs["reload_dir"] = tmpdir
+#         write_json_config_app(filename, config_file, **runargs)
+#         text = write_file(config_file)
+#         command = argv[mode]
+#         if mode == "sanic":
+#             command += ["--reload-dir", tmpdir]
+#         proc = Popen(command, cwd=tmpdir, stdout=PIPE, creationflags=flags)
+#         try:
+#             timeout = Timer(TIMER_DELAY, terminate, [proc])
+#             timeout.start()
+#             # Python apparently keeps using the old source sometimes if
+#             # we don't sleep before rewrite (pycache timestamp problem?)
+#             sleep(1)
+#             line = scanner(proc)
+#             assert text in next(line)
+#             # Edit source code and try again
+#             text = write_file(config_file)
+#             assert text in next(line)
+#         finally:
+#             timeout.cancel()
+#             terminate(proc)
+#             with suppress(TimeoutExpired):
+#                 proc.wait(timeout=3)
+
+
+# def test_reload_listeners():
+#     with TemporaryDirectory() as tmpdir:
+#         filename = os.path.join(tmpdir, "reloader.py")
+#         start_text, stop_text = write_listener_app(
+#             filename, port=42305, auto_reload=True
+#         )
+
+#         proc = Popen(
+#             argv["script"], cwd=tmpdir, stdout=PIPE, creationflags=flags
+#         )
+#         try:
+#             timeout = Timer(TIMER_DELAY, terminate, [proc])
+#             timeout.start()
+#             # Python apparently keeps using the old source sometimes if
+#             # we don't sleep before rewrite (pycache timestamp problem?)
+#             sleep(1)
+#             line = scanner(proc, "reload_start")
+#             assert start_text in next(line)
+#             line = scanner(proc, "reload_stop")
+#             assert stop_text in next(line)
+#         finally:
+#             timeout.cancel()
+#             terminate(proc)
+#             with suppress(TimeoutExpired):
+#                 proc.wait(timeout=3)
diff --git a/tests/test_requests.py b/tests/test_requests.py
index 28a211db..78fc488a 100644
--- a/tests/test_requests.py
+++ b/tests/test_requests.py
@@ -2244,18 +2244,26 @@ def test_conflicting_body_methods_overload(app: Sanic):
     @app.put("/p/<foo>", name="three")
     async def put(request, foo=None):
         return json(
-            {"name": request.route.name, "body": str(request.body), "foo": foo}
+            {
+                "name": request.route.name,
+                "body": str(request.body).replace(" ", ""),
+                "foo": foo,
+            }
         )
 
     @app.delete("/p/<foo>")
     async def delete(request, foo):
         return json(
-            {"name": request.route.name, "body": str(request.body), "foo": foo}
+            {
+                "name": request.route.name,
+                "body": str(request.body).replace(" ", ""),
+                "foo": foo,
+            }
         )
 
     dumps = BaseHTTPResponse._dumps
     payload = {"test": "OK"}
-    data = str(dumps(payload).encode())
+    data = str(dumps(payload).encode()).replace(" ", "")
 
     _, response = app.test_client.put("/", json=payload)
     assert response.status == 200
diff --git a/tests/worker/test_manager.py b/tests/worker/test_manager.py
index 53180d6c..d75e6c80 100644
--- a/tests/worker/test_manager.py
+++ b/tests/worker/test_manager.py
@@ -66,10 +66,12 @@ def test_kill(os_mock: Mock):
     process.pid = 1234
     context = Mock()
     context.Process.return_value = process
+    os_mock.getpgid.return_value = 5678
     manager = WorkerManager(1, fake_serve, {}, context, (Mock(), Mock()), {})
     with pytest.raises(ServerKilled):
         manager.kill()
-    os_mock.kill.assert_called_once_with(1234, SIGKILL)
+    os_mock.getpgid.assert_called_once_with(1234)
+    os_mock.killpg.assert_called_once_with(5678, SIGKILL)
 
 
 @patch("sanic.worker.process.os")
@@ -81,13 +83,15 @@ def test_shutdown_signal_send_kill(
     process.pid = 1234
     context = Mock()
     context.Process.return_value = process
+    manager_os_mock.getpgid.return_value = 5678
     manager = WorkerManager(1, fake_serve, {}, context, (Mock(), Mock()), {})
     assert manager._shutting_down is False
     manager.shutdown_signal(SIGINT, None)
     assert manager._shutting_down is True
     process_os_mock.kill.assert_called_once_with(1234, SIGINT)
     manager.shutdown_signal(SIGINT, None)
-    manager_os_mock.kill.assert_called_once_with(1234, SIGKILL)
+    manager_os_mock.getpgid.assert_called_once_with(1234)
+    manager_os_mock.killpg.assert_called_once_with(5678, SIGKILL)
 
 
 def test_restart_all():