Compare commits

...

10 Commits

Author SHA1 Message Date
Adam Hopkins
f83553be9e
Remove unused import 2022-12-12 15:12:47 +02:00
Adam Hopkins
0c3527b8b2
Cleanup typing 2022-12-12 12:20:55 +02:00
Adam Hopkins
232bbce1e0
Cleanup tests 2022-12-12 12:04:12 +02:00
Adam Hopkins
f034a31d29
Reorganize tests 2022-12-12 11:44:42 +02:00
Adam Hopkins
3536c0af27
Fix caps on worker prefix 2022-12-12 11:29:02 +02:00
Adam Hopkins
0e7ee94574
Add test 2022-12-12 11:26:29 +02:00
Adam Hopkins
4e4e2b036b
Merge branch 'main' of github.com:sanic-org/sanic into monitor-restart 2022-12-12 09:41:09 +02:00
Adam Hopkins
7f682cea02
Add ordering config 2022-12-11 10:59:30 +02:00
Adam Hopkins
ae1669cd8f
Merge branch 'main' of github.com:sanic-org/sanic into monitor-restart 2022-12-11 10:38:30 +02:00
Adam Hopkins
3c4c136090
Wait for new process to ACK before termination of old on restart 2022-12-08 14:46:45 +02:00
12 changed files with 242 additions and 68 deletions

View File

@ -8,11 +8,6 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from sanic import Sanic from sanic import Sanic
try:
from sanic_ext import Extend # type: ignore
except ImportError:
...
def setup_ext(app: Sanic, *, fail: bool = False, **kwargs): def setup_ext(app: Sanic, *, fail: bool = False, **kwargs):
if not app.config.AUTO_EXTEND: if not app.config.AUTO_EXTEND:
@ -33,7 +28,7 @@ def setup_ext(app: Sanic, *, fail: bool = False, **kwargs):
return return
if not getattr(app, "_ext", None): if not getattr(app, "_ext", None):
Ext: Extend = getattr(sanic_ext, "Extend") Ext = getattr(sanic_ext, "Extend")
app._ext = Ext(app, **kwargs) app._ext = Ext(app, **kwargs)
return app.ext return app.ext

View File

@ -3,6 +3,7 @@ import os
import signal import signal
import sys import sys
from enum import Enum
from typing import Awaitable from typing import Awaitable
from multidict import CIMultiDict # type: ignore from multidict import CIMultiDict # type: ignore
@ -19,6 +20,31 @@ except ImportError:
pass pass
# Python 3.11 changed the way Enum formatting works for mixed-in types.
if sys.version_info < (3, 11, 0):
class StrEnum(str, Enum):
pass
else:
from enum import StrEnum # type: ignore # noqa
class UpperStrEnum(StrEnum):
def _generate_next_value_(name, start, count, last_values):
return name.upper()
def __eq__(self, value: object) -> bool:
value = str(value).upper()
return super().__eq__(value)
def __hash__(self) -> int:
return hash(self.value)
def __str__(self) -> str:
return self.value
def enable_windows_color_support(): def enable_windows_color_support():
import ctypes import ctypes

View File

@ -8,7 +8,7 @@ from pathlib import Path
from typing import Any, Callable, Dict, Optional, Sequence, Union from typing import Any, Callable, Dict, Optional, Sequence, Union
from warnings import filterwarnings from warnings import filterwarnings
from sanic.constants import LocalCertCreator from sanic.constants import LocalCertCreator, RestartOrder
from sanic.errorpages import DEFAULT_FORMAT, check_error_format from sanic.errorpages import DEFAULT_FORMAT, check_error_format
from sanic.helpers import Default, _default from sanic.helpers import Default, _default
from sanic.http import Http from sanic.http import Http
@ -63,6 +63,7 @@ DEFAULT_CONFIG = {
"REQUEST_MAX_SIZE": 100000000, # 100 megabytes "REQUEST_MAX_SIZE": 100000000, # 100 megabytes
"REQUEST_TIMEOUT": 60, # 60 seconds "REQUEST_TIMEOUT": 60, # 60 seconds
"RESPONSE_TIMEOUT": 60, # 60 seconds "RESPONSE_TIMEOUT": 60, # 60 seconds
"RESTART_ORDER": RestartOrder.SHUTDOWN_FIRST,
"TLS_CERT_PASSWORD": "", "TLS_CERT_PASSWORD": "",
"TOUCHUP": _default, "TOUCHUP": _default,
"USE_UVLOOP": _default, "USE_UVLOOP": _default,
@ -110,6 +111,7 @@ class Config(dict, metaclass=DescriptorMeta):
REQUEST_MAX_SIZE: int REQUEST_MAX_SIZE: int
REQUEST_TIMEOUT: int REQUEST_TIMEOUT: int
RESPONSE_TIMEOUT: int RESPONSE_TIMEOUT: int
RESTART_ORDER: Union[str, RestartOrder]
SERVER_NAME: str SERVER_NAME: str
TLS_CERT_PASSWORD: str TLS_CERT_PASSWORD: str
TOUCHUP: Union[Default, bool] TOUCHUP: Union[Default, bool]
@ -194,6 +196,10 @@ class Config(dict, metaclass=DescriptorMeta):
self.LOCAL_CERT_CREATOR = LocalCertCreator[ self.LOCAL_CERT_CREATOR = LocalCertCreator[
self.LOCAL_CERT_CREATOR.upper() self.LOCAL_CERT_CREATOR.upper()
] ]
elif attr == "RESTART_ORDER" and not isinstance(
self.RESTART_ORDER, RestartOrder
):
self.RESTART_ORDER = RestartOrder[self.RESTART_ORDER.upper()]
elif attr == "DEPRECATION_FILTER": elif attr == "DEPRECATION_FILTER":
self._configure_warnings() self._configure_warnings()

View File

@ -1,19 +1,9 @@
from enum import Enum, auto from enum import auto
from sanic.compat import UpperStrEnum
class HTTPMethod(str, Enum): class HTTPMethod(UpperStrEnum):
def _generate_next_value_(name, start, count, last_values):
return name.upper()
def __eq__(self, value: object) -> bool:
value = str(value).upper()
return super().__eq__(value)
def __hash__(self) -> int:
return hash(self.value)
def __str__(self) -> str:
return self.value
GET = auto() GET = auto()
POST = auto() POST = auto()
@ -24,15 +14,19 @@ class HTTPMethod(str, Enum):
DELETE = auto() DELETE = auto()
class LocalCertCreator(str, Enum): class LocalCertCreator(UpperStrEnum):
def _generate_next_value_(name, start, count, last_values):
return name.upper()
AUTO = auto() AUTO = auto()
TRUSTME = auto() TRUSTME = auto()
MKCERT = auto() MKCERT = auto()
class RestartOrder(UpperStrEnum):
SHUTDOWN_FIRST = auto()
STARTUP_FIRST = auto()
HTTP_METHODS = tuple(HTTPMethod.__members__.values()) HTTP_METHODS = tuple(HTTPMethod.__members__.values())
SAFE_HTTP_METHODS = (HTTPMethod.GET, HTTPMethod.HEAD, HTTPMethod.OPTIONS) SAFE_HTTP_METHODS = (HTTPMethod.GET, HTTPMethod.HEAD, HTTPMethod.OPTIONS)
IDEMPOTENT_HTTP_METHODS = ( IDEMPOTENT_HTTP_METHODS = (

View File

@ -1,22 +1,10 @@
import logging import logging
import sys import sys
from enum import Enum from typing import Any, Dict
from typing import TYPE_CHECKING, Any, Dict
from warnings import warn from warnings import warn
from sanic.compat import is_atty from sanic.compat import StrEnum, is_atty
# Python 3.11 changed the way Enum formatting works for mixed-in types.
if sys.version_info < (3, 11, 0):
class StrEnum(str, Enum):
pass
else:
if not TYPE_CHECKING:
from enum import StrEnum
LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( # no cov LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( # no cov

View File

@ -41,6 +41,7 @@ from sanic.application.motd import MOTD
from sanic.application.state import ApplicationServerInfo, Mode, ServerStage from sanic.application.state import ApplicationServerInfo, Mode, ServerStage
from sanic.base.meta import SanicMeta from sanic.base.meta import SanicMeta
from sanic.compat import OS_IS_WINDOWS, is_atty from sanic.compat import OS_IS_WINDOWS, is_atty
from sanic.constants import RestartOrder
from sanic.exceptions import ServerKilled from sanic.exceptions import ServerKilled
from sanic.helpers import Default from sanic.helpers import Default
from sanic.http.constants import HTTP from sanic.http.constants import HTTP
@ -814,6 +815,7 @@ class StartupMixin(metaclass=SanicMeta):
cls._get_context(), cls._get_context(),
(monitor_pub, monitor_sub), (monitor_pub, monitor_sub),
worker_state, worker_state,
cast(RestartOrder, primary.config.RESTART_ORDER),
) )
if cls.should_auto_reload(): if cls.should_auto_reload():
reload_dirs: Set[Path] = primary.state.reload_dirs.union( reload_dirs: Set[Path] = primary.state.reload_dirs.union(

View File

@ -5,6 +5,7 @@ from signal import signal as signal_func
from typing import List, Optional from typing import List, Optional
from sanic.compat import OS_IS_WINDOWS from sanic.compat import OS_IS_WINDOWS
from sanic.constants import RestartOrder
from sanic.exceptions import ServerKilled from sanic.exceptions import ServerKilled
from sanic.log import error_logger, logger from sanic.log import error_logger, logger
from sanic.worker.process import ProcessState, Worker, WorkerProcess from sanic.worker.process import ProcessState, Worker, WorkerProcess
@ -18,6 +19,7 @@ else:
class WorkerManager: class WorkerManager:
THRESHOLD = 300 # == 30 seconds THRESHOLD = 300 # == 30 seconds
MAIN_IDENT = "Sanic-Main"
def __init__( def __init__(
self, self,
@ -27,6 +29,7 @@ class WorkerManager:
context, context,
monitor_pubsub, monitor_pubsub,
worker_state, worker_state,
restart_order: RestartOrder = RestartOrder.SHUTDOWN_FIRST,
): ):
self.num_server = number self.num_server = number
self.context = context self.context = context
@ -34,8 +37,9 @@ class WorkerManager:
self.durable: List[Worker] = [] self.durable: List[Worker] = []
self.monitor_publisher, self.monitor_subscriber = monitor_pubsub self.monitor_publisher, self.monitor_subscriber = monitor_pubsub
self.worker_state = worker_state self.worker_state = worker_state
self.worker_state["Sanic-Main"] = {"pid": self.pid} self.worker_state[self.MAIN_IDENT] = {"pid": self.pid}
self.terminated = False self.terminated = False
self.restart_order = restart_order
if number == 0: if number == 0:
raise RuntimeError("Cannot serve with no workers") raise RuntimeError("Cannot serve with no workers")
@ -54,7 +58,14 @@ class WorkerManager:
def manage(self, ident, func, kwargs, transient=False): def manage(self, ident, func, kwargs, transient=False):
container = self.transient if transient else self.durable container = self.transient if transient else self.durable
container.append( container.append(
Worker(ident, func, kwargs, self.context, self.worker_state) Worker(
ident,
func,
kwargs,
self.context,
self.worker_state,
self.restart_order,
)
) )
def run(self): def run(self):
@ -122,11 +133,18 @@ class WorkerManager:
process_names=process_names, process_names=process_names,
reloaded_files=reloaded_files, reloaded_files=reloaded_files,
) )
self._sync_states()
except InterruptedError: except InterruptedError:
if not OS_IS_WINDOWS: if not OS_IS_WINDOWS:
raise raise
break break
def _sync_states(self):
for process in self.processes:
state = self.worker_state[process.name].get("state")
if state and process.state.name != state:
process.set_state(ProcessState[state], True)
def wait_for_ack(self): # no cov def wait_for_ack(self): # no cov
misses = 0 misses = 0
message = ( message = (

View File

@ -4,8 +4,10 @@ from datetime import datetime, timezone
from enum import IntEnum, auto from enum import IntEnum, auto
from multiprocessing.context import BaseContext from multiprocessing.context import BaseContext
from signal import SIGINT from signal import SIGINT
from threading import Thread
from typing import Any, Dict, Set from typing import Any, Dict, Set
from sanic.constants import RestartOrder
from sanic.log import Colors, logger from sanic.log import Colors, logger
@ -16,6 +18,8 @@ def get_now():
class ProcessState(IntEnum): class ProcessState(IntEnum):
IDLE = auto() IDLE = auto()
RESTARTING = auto()
STARTING = auto()
STARTED = auto() STARTED = auto()
ACKED = auto() ACKED = auto()
JOINED = auto() JOINED = auto()
@ -25,13 +29,22 @@ class ProcessState(IntEnum):
class WorkerProcess: class WorkerProcess:
SERVER_LABEL = "Server" SERVER_LABEL = "Server"
def __init__(self, factory, name, target, kwargs, worker_state): def __init__(
self,
factory,
name,
target,
kwargs,
worker_state,
restart_order: RestartOrder,
):
self.state = ProcessState.IDLE self.state = ProcessState.IDLE
self.factory = factory self.factory = factory
self.name = name self.name = name
self.target = target self.target = target
self.kwargs = kwargs self.kwargs = kwargs
self.worker_state = worker_state self.worker_state = worker_state
self.restart_order = restart_order
if self.name not in self.worker_state: if self.name not in self.worker_state:
self.worker_state[self.name] = { self.worker_state[self.name] = {
"server": self.SERVER_LABEL in self.name "server": self.SERVER_LABEL in self.name
@ -54,8 +67,9 @@ class WorkerProcess:
f"{Colors.SANIC}%s{Colors.END}", f"{Colors.SANIC}%s{Colors.END}",
self.name, self.name,
) )
self.set_state(ProcessState.STARTING)
self._current_process.start()
self.set_state(ProcessState.STARTED) self.set_state(ProcessState.STARTED)
self._process.start()
if not self.worker_state[self.name].get("starts"): if not self.worker_state[self.name].get("starts"):
self.worker_state[self.name] = { self.worker_state[self.name] = {
**self.worker_state[self.name], **self.worker_state[self.name],
@ -67,7 +81,7 @@ class WorkerProcess:
def join(self): def join(self):
self.set_state(ProcessState.JOINED) self.set_state(ProcessState.JOINED)
self._process.join() self._current_process.join()
def terminate(self): def terminate(self):
if self.state is not ProcessState.TERMINATED: if self.state is not ProcessState.TERMINATED:
@ -80,7 +94,6 @@ class WorkerProcess:
) )
self.set_state(ProcessState.TERMINATED, force=True) self.set_state(ProcessState.TERMINATED, force=True)
try: try:
# self._process.terminate()
os.kill(self.pid, SIGINT) os.kill(self.pid, SIGINT)
del self.worker_state[self.name] del self.worker_state[self.name]
except (KeyError, AttributeError, ProcessLookupError): except (KeyError, AttributeError, ProcessLookupError):
@ -93,8 +106,11 @@ class WorkerProcess:
self.name, self.name,
self.pid, self.pid,
) )
self._process.terminate() self.set_state(ProcessState.RESTARTING, force=True)
self.set_state(ProcessState.IDLE, force=True) if self.restart_order is RestartOrder.SHUTDOWN_FIRST:
self._terminate_now()
else:
self._old_process = self._current_process
self.kwargs.update( self.kwargs.update(
{"config": {k.upper(): v for k, v in kwargs.items()}} {"config": {k.upper(): v for k, v in kwargs.items()}}
) )
@ -104,6 +120,9 @@ class WorkerProcess:
except AttributeError: except AttributeError:
raise RuntimeError("Restart failed") raise RuntimeError("Restart failed")
if self.restart_order is RestartOrder.STARTUP_FIRST:
self._terminate_soon()
self.worker_state[self.name] = { self.worker_state[self.name] = {
**self.worker_state[self.name], **self.worker_state[self.name],
"pid": self.pid, "pid": self.pid,
@ -111,16 +130,59 @@ class WorkerProcess:
"restart_at": get_now(), "restart_at": get_now(),
} }
def _terminate_now(self):
logger.debug(
f"{Colors.BLUE}Begin restart termination: "
f"{Colors.BOLD}{Colors.SANIC}"
f"%s {Colors.BLUE}[%s]{Colors.END}",
self.name,
self._current_process.pid,
)
self._current_process.terminate()
def _terminate_soon(self):
logger.debug(
f"{Colors.BLUE}Begin restart termination: "
f"{Colors.BOLD}{Colors.SANIC}"
f"%s {Colors.BLUE}[%s]{Colors.END}",
self.name,
self._current_process.pid,
)
termination_thread = Thread(target=self._wait_to_terminate)
termination_thread.start()
def _wait_to_terminate(self):
logger.debug(
f"{Colors.BLUE}Waiting for process to be acked: "
f"{Colors.BOLD}{Colors.SANIC}"
f"%s {Colors.BLUE}[%s]{Colors.END}",
self.name,
self._old_process.pid,
)
# TODO: Add a timeout?
while self.state is not ProcessState.ACKED:
...
else:
logger.debug(
f"{Colors.BLUE}Process acked. Terminating: "
f"{Colors.BOLD}{Colors.SANIC}"
f"%s {Colors.BLUE}[%s]{Colors.END}",
self.name,
self._old_process.pid,
)
self._old_process.terminate()
delattr(self, "_old_process")
def is_alive(self): def is_alive(self):
try: try:
return self._process.is_alive() return self._current_process.is_alive()
except AssertionError: except AssertionError:
return False return False
def spawn(self): def spawn(self):
if self.state is not ProcessState.IDLE: if self.state not in (ProcessState.IDLE, ProcessState.RESTARTING):
raise Exception("Cannot spawn a worker process until it is idle.") raise Exception("Cannot spawn a worker process until it is idle.")
self._process = self.factory( self._current_process = self.factory(
name=self.name, name=self.name,
target=self.target, target=self.target,
kwargs=self.kwargs, kwargs=self.kwargs,
@ -129,10 +191,12 @@ class WorkerProcess:
@property @property
def pid(self): def pid(self):
return self._process.pid return self._current_process.pid
class Worker: class Worker:
WORKER_PREFIX = "Sanic-"
def __init__( def __init__(
self, self,
ident: str, ident: str,
@ -140,22 +204,25 @@ class Worker:
server_settings, server_settings,
context: BaseContext, context: BaseContext,
worker_state: Dict[str, Any], worker_state: Dict[str, Any],
restart_order: RestartOrder,
): ):
self.ident = ident self.ident = f"{self.WORKER_PREFIX}{ident}"
self.context = context self.context = context
self.serve = serve self.serve = serve
self.server_settings = server_settings self.server_settings = server_settings
self.worker_state = worker_state self.worker_state = worker_state
self.processes: Set[WorkerProcess] = set() self.processes: Set[WorkerProcess] = set()
self.restart_order = restart_order
self.create_process() self.create_process()
def create_process(self) -> WorkerProcess: def create_process(self) -> WorkerProcess:
process = WorkerProcess( process = WorkerProcess(
factory=self.context.Process, factory=self.context.Process,
name=f"Sanic-{self.ident}-{len(self.processes)}", name=f"{self.ident}-{len(self.processes)}",
target=self.serve, target=self.serve,
kwargs={**self.server_settings}, kwargs={**self.server_settings},
worker_state=self.worker_state, worker_state=self.worker_state,
restart_order=self.restart_order,
) )
self.processes.add(process) self.processes.add(process)
return process return process

View File

@ -353,7 +353,7 @@ def test_get_app_does_not_exist():
"if __name__ == '__main__' " "if __name__ == '__main__' "
"block or by using an AppLoader.\nSee " "block or by using an AppLoader.\nSee "
"https://sanic.dev/en/guide/deployment/app-loader.html" "https://sanic.dev/en/guide/deployment/app-loader.html"
" for more details." " for more details.",
): ):
Sanic.get_app("does-not-exist") Sanic.get_app("does-not-exist")

View File

@ -14,7 +14,7 @@ from pytest import MonkeyPatch
from sanic import Sanic from sanic import Sanic
from sanic.config import DEFAULT_CONFIG, Config from sanic.config import DEFAULT_CONFIG, Config
from sanic.constants import LocalCertCreator from sanic.constants import LocalCertCreator, RestartOrder
from sanic.exceptions import PyFileError from sanic.exceptions import PyFileError
@ -436,3 +436,19 @@ def test_convert_local_cert_creator(passed, expected):
app = Sanic("Test") app = Sanic("Test")
assert app.config.LOCAL_CERT_CREATOR is expected assert app.config.LOCAL_CERT_CREATOR is expected
del os.environ["SANIC_LOCAL_CERT_CREATOR"] del os.environ["SANIC_LOCAL_CERT_CREATOR"]
@pytest.mark.parametrize(
"passed,expected",
(
("shutdown_first", RestartOrder.SHUTDOWN_FIRST),
("startup_first", RestartOrder.STARTUP_FIRST),
("SHUTDOWN_FIRST", RestartOrder.SHUTDOWN_FIRST),
("STARTUP_FIRST", RestartOrder.STARTUP_FIRST),
),
)
def test_convert_restart_order(passed, expected):
os.environ["SANIC_RESTART_ORDER"] = passed
app = Sanic("Test")
assert app.config.RESTART_ORDER is expected
del os.environ["SANIC_RESTART_ORDER"]

View File

@ -10,8 +10,7 @@ import pytest
import sanic import sanic
from sanic import Sanic from sanic import Sanic
from sanic.log import Colors from sanic.log import LOGGING_CONFIG_DEFAULTS, Colors, logger
from sanic.log import LOGGING_CONFIG_DEFAULTS, logger
from sanic.response import text from sanic.response import text
@ -254,11 +253,11 @@ def test_verbosity(app, caplog, app_verbosity, log_verbosity, exists):
def test_colors_enum_format(): def test_colors_enum_format():
assert f'{Colors.END}' == Colors.END.value assert f"{Colors.END}" == Colors.END.value
assert f'{Colors.BOLD}' == Colors.BOLD.value assert f"{Colors.BOLD}" == Colors.BOLD.value
assert f'{Colors.BLUE}' == Colors.BLUE.value assert f"{Colors.BLUE}" == Colors.BLUE.value
assert f'{Colors.GREEN}' == Colors.GREEN.value assert f"{Colors.GREEN}" == Colors.GREEN.value
assert f'{Colors.PURPLE}' == Colors.PURPLE.value assert f"{Colors.PURPLE}" == Colors.PURPLE.value
assert f'{Colors.RED}' == Colors.RED.value assert f"{Colors.RED}" == Colors.RED.value
assert f'{Colors.SANIC}' == Colors.SANIC.value assert f"{Colors.SANIC}" == Colors.SANIC.value
assert f'{Colors.YELLOW}' == Colors.YELLOW.value assert f"{Colors.YELLOW}" == Colors.YELLOW.value

View File

@ -1,13 +1,18 @@
import re
import signal import signal
import threading
from asyncio import Event from asyncio import Event
from logging import DEBUG
from pathlib import Path from pathlib import Path
from unittest.mock import Mock from unittest.mock import Mock
import pytest import pytest
from sanic.app import Sanic from sanic.app import Sanic
from sanic.constants import RestartOrder
from sanic.worker.loader import AppLoader from sanic.worker.loader import AppLoader
from sanic.worker.process import ProcessState, WorkerProcess
from sanic.worker.reloader import Reloader from sanic.worker.reloader import Reloader
@ -67,6 +72,64 @@ def test_iter_files():
assert len_total_files == len_python_files + len_static_files assert len_total_files == len_python_files + len_static_files
@pytest.mark.parametrize(
"order,expected",
(
(
"shutdown_first",
[
"Restarting a process",
"Begin restart termination",
"Starting a process",
],
),
(
"startup_first",
[
"Restarting a process",
"Starting a process",
"Begin restart termination",
"Waiting for process to be acked",
"Process acked. Terminating",
],
),
),
)
def test_default_reload_shutdown_order(monkeypatch, caplog, order, expected):
current_process = Mock()
worker_process = WorkerProcess(
lambda **_: current_process,
"Test",
lambda **_: ...,
{},
{},
RestartOrder[order.upper()],
)
def start(self):
worker_process.set_state(ProcessState.ACKED)
self._target()
orig = threading.Thread.start
monkeypatch.setattr(threading.Thread, "start", start)
with caplog.at_level(DEBUG):
worker_process.restart()
ansi = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def clean(msg: str):
msg, _ = ansi.sub("", msg).split(":", 1)
return msg
debug = [clean(record[2]) for record in caplog.record_tuples]
assert debug == expected
current_process.start.assert_called_once()
current_process.terminate.assert_called_once()
monkeypatch.setattr(threading.Thread, "start", orig)
def test_reloader_triggers_start_stop_listeners( def test_reloader_triggers_start_stop_listeners(
app: Sanic, app_loader: AppLoader app: Sanic, app_loader: AppLoader
): ):