Skip to content

Commit b7d4597

Browse files
committed
Shut down the multiprocess supervisor when a worker dies before startup
1 parent eea1bcc commit b7d4597

5 files changed

Lines changed: 105 additions & 31 deletions

File tree

tests/supervisors/test_multiprocess.py

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import functools
44
import os
55
import signal
6-
import socket
76
import threading
87
import time
98
from collections.abc import Callable
@@ -13,6 +12,7 @@
1312

1413
from uvicorn import Config
1514
from uvicorn._types import ASGIReceiveCallable, ASGISendCallable, Scope
15+
from uvicorn.server import STARTUP_FAILURE
1616
from uvicorn.supervisors import Multiprocess
1717
from uvicorn.supervisors.multiprocess import Process
1818

@@ -41,22 +41,32 @@ async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
4141
pass # pragma: no cover
4242

4343

44-
def run(sockets: list[socket.socket] | None) -> None:
45-
while True: # pragma: no cover
46-
time.sleep(1)
44+
async def lifespan_failure_app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None:
45+
assert scope["type"] == "lifespan"
46+
message = await receive()
47+
if message["type"] == "lifespan.startup":
48+
raise RuntimeError("Startup failed")
4749

4850

4951
def test_process_ping_pong() -> None:
50-
process = Process(Config(app=app), target=lambda x: None, sockets=[])
52+
process = Process(Config(app=app), sockets=[])
5153
threading.Thread(target=process.always_pong, daemon=True).start()
5254
assert process.ping()
5355

5456

5557
def test_process_ping_pong_timeout() -> None:
56-
process = Process(Config(app=app), target=lambda x: None, sockets=[])
58+
process = Process(Config(app=app), sockets=[])
5759
assert not process.ping(0.1)
5860

5961

62+
def test_process_ready() -> None:
63+
process = Process(Config(app=app), sockets=[])
64+
assert not process.ready
65+
process.notify_started()
66+
assert process.ready
67+
assert process.ready
68+
69+
6070
@new_console_in_windows
6171
def test_multiprocess_run() -> None:
6272
"""
@@ -66,7 +76,7 @@ def test_multiprocess_run() -> None:
6676
quit immediately.
6777
"""
6878
config = Config(app=app, workers=2)
69-
supervisor = Multiprocess(config, target=run, sockets=[])
79+
supervisor = Multiprocess(config, sockets=[])
7080
threading.Thread(target=supervisor.run, daemon=True).start()
7181
supervisor.signal_queue.append(signal.SIGINT)
7282
supervisor.join_all()
@@ -78,9 +88,12 @@ def test_multiprocess_health_check() -> None:
7888
Ensure that the health check works as expected.
7989
"""
8090
config = Config(app=app, workers=2)
81-
supervisor = Multiprocess(config, target=run, sockets=[])
91+
supervisor = Multiprocess(config, sockets=[])
8292
threading.Thread(target=supervisor.run, daemon=True).start()
83-
time.sleep(1)
93+
deadline = time.monotonic() + 10
94+
while len(supervisor.processes) < 2 or not all(p.ready for p in supervisor.processes): # pragma: no cover
95+
assert time.monotonic() < deadline, "Timed out waiting for processes to become ready"
96+
time.sleep(0.1)
8497
process = supervisor.processes[0]
8598
process.kill()
8699
assert not process.is_alive()
@@ -92,13 +105,32 @@ def test_multiprocess_health_check() -> None:
92105
supervisor.join_all()
93106

94107

108+
def test_multiprocess_stops_on_startup_failure() -> None:
109+
"""A worker that dies before startup completes shuts the supervisor down instead of
110+
being restarted forever (https://github.com/encode/uvicorn/discussions/2440)."""
111+
config = Config(app="tests.supervisors.test_multiprocess:app_does_not_exist", workers=2)
112+
supervisor = Multiprocess(config, sockets=[])
113+
with pytest.raises(SystemExit) as exc_info:
114+
supervisor.run()
115+
assert exc_info.value.code == STARTUP_FAILURE
116+
117+
118+
def test_multiprocess_stops_on_lifespan_startup_failure() -> None:
119+
"""A lifespan startup failure is a startup failure too, not a reason to restart the worker."""
120+
config = Config(app=lifespan_failure_app, lifespan="on", workers=2)
121+
supervisor = Multiprocess(config, sockets=[])
122+
with pytest.raises(SystemExit) as exc_info:
123+
supervisor.run()
124+
assert exc_info.value.code == STARTUP_FAILURE
125+
126+
95127
@new_console_in_windows
96128
def test_multiprocess_sigterm() -> None:
97129
"""
98130
Ensure that the SIGTERM signal is handled as expected.
99131
"""
100132
config = Config(app=app, workers=2)
101-
supervisor = Multiprocess(config, target=run, sockets=[])
133+
supervisor = Multiprocess(config, sockets=[])
102134
threading.Thread(target=supervisor.run, daemon=True).start()
103135
time.sleep(1)
104136
supervisor.signal_queue.append(signal.SIGTERM)
@@ -112,7 +144,7 @@ def test_multiprocess_sigbreak() -> None: # pragma: py-not-win32
112144
Ensure that the SIGBREAK signal is handled as expected.
113145
"""
114146
config = Config(app=app, workers=2)
115-
supervisor = Multiprocess(config, target=run, sockets=[])
147+
supervisor = Multiprocess(config, sockets=[])
116148
threading.Thread(target=supervisor.run, daemon=True).start()
117149
time.sleep(1)
118150
supervisor.signal_queue.append(getattr(signal, "SIGBREAK"))
@@ -125,7 +157,7 @@ def test_multiprocess_sighup() -> None:
125157
Ensure that the SIGHUP signal is handled as expected.
126158
"""
127159
config = Config(app=app, workers=2)
128-
supervisor = Multiprocess(config, target=run, sockets=[])
160+
supervisor = Multiprocess(config, sockets=[])
129161
threading.Thread(target=supervisor.run, daemon=True).start()
130162
time.sleep(1)
131163
pids = [p.pid for p in supervisor.processes]
@@ -148,7 +180,7 @@ def test_multiprocess_sigttin() -> None:
148180
Ensure that the SIGTTIN signal is handled as expected.
149181
"""
150182
config = Config(app=app, workers=2)
151-
supervisor = Multiprocess(config, target=run, sockets=[])
183+
supervisor = Multiprocess(config, sockets=[])
152184
threading.Thread(target=supervisor.run, daemon=True).start()
153185
supervisor.signal_queue.append(signal.SIGTTIN)
154186
time.sleep(1)
@@ -163,7 +195,7 @@ def test_multiprocess_sigttou() -> None:
163195
Ensure that the SIGTTOU signal is handled as expected.
164196
"""
165197
config = Config(app=app, workers=2)
166-
supervisor = Multiprocess(config, target=run, sockets=[])
198+
supervisor = Multiprocess(config, sockets=[])
167199
threading.Thread(target=supervisor.run, daemon=True).start()
168200
supervisor.signal_queue.append(signal.SIGTTOU)
169201
time.sleep(1)

tests/test_main.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import importlib
23
import inspect
34
import socket
@@ -167,6 +168,20 @@ def test_run_match_config_params() -> None:
167168
assert config_params == run_params
168169

169170

171+
async def test_server_on_started_callback(unused_tcp_port: int) -> None:
172+
config = Config(app=app, loop="asyncio", port=unused_tcp_port)
173+
server = Server(config=config)
174+
started_events: list[bool] = []
175+
server.on_started = lambda: started_events.append(server.started)
176+
177+
task = asyncio.create_task(server.serve())
178+
while not server.started:
179+
await asyncio.sleep(0.01)
180+
assert started_events == [True]
181+
await server.shutdown()
182+
task.cancel()
183+
184+
170185
async def test_exit_on_create_server_with_invalid_host() -> None:
171186
with pytest.raises(SystemExit) as exc_info:
172187
config = Config(app=app, host="illegal_host")

uvicorn/main.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
LoopFactoryType,
2929
WSProtocolType,
3030
)
31-
from uvicorn.server import Server
31+
from uvicorn.server import STARTUP_FAILURE, Server
3232
from uvicorn.supervisors import ChangeReload, Multiprocess
3333

3434
LEVEL_CHOICES = click.Choice(list(LOG_LEVELS.keys()))
@@ -40,8 +40,6 @@ def _metavar_from_type(_type: Any) -> str:
4040
return f"[{'|'.join(key for key in get_args(_type) if key != 'none')}]"
4141

4242

43-
STARTUP_FAILURE = 3
44-
4543
logger = logging.getLogger("uvicorn.error")
4644

4745

@@ -615,7 +613,7 @@ def run(
615613
ChangeReload(config, target=server.run, sockets=[sock]).run()
616614
elif config.workers > 1:
617615
sock = config.bind_socket()
618-
Multiprocess(config, target=server.run, sockets=[sock]).run()
616+
Multiprocess(config, sockets=[sock]).run()
619617
else:
620618
server.run()
621619
except KeyboardInterrupt: # pragma: full coverage

uvicorn/server.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import sys
1313
import threading
1414
import time
15-
from collections.abc import Generator, Sequence
15+
from collections.abc import Callable, Generator, Sequence
1616
from email.utils import formatdate
1717
from types import FrameType
1818
from typing import TYPE_CHECKING, TypeAlias
@@ -40,6 +40,8 @@
4040

4141
logger = logging.getLogger("uvicorn.error")
4242

43+
STARTUP_FAILURE = 3
44+
4345

4446
class ServerState:
4547
"""
@@ -62,6 +64,7 @@ def __init__(self, config: Config) -> None:
6264
self.should_exit = False
6365
self.force_exit = False
6466
self.last_notified = 0.0
67+
self.on_started: Callable[[], None] | None = None
6568

6669
self._captured_signals: list[int] = []
6770

@@ -193,6 +196,8 @@ def _share_socket(
193196
pass # pragma: full coverage
194197

195198
self.started = True
199+
if self.on_started is not None:
200+
self.on_started()
196201

197202
def _log_started_message(self, listeners: Sequence[socket.SocketType]) -> None:
198203
config = self.config

uvicorn/supervisors/multiprocess.py

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@
33
import logging
44
import os
55
import signal
6+
import sys
67
import threading
7-
from collections.abc import Callable
88
from multiprocessing import Pipe
99
from socket import socket
10-
from typing import Any
1110

1211
import click
1312

1413
from uvicorn._subprocess import get_subprocess
1514
from uvicorn.config import Config
15+
from uvicorn.server import STARTUP_FAILURE, Server
1616

1717
SIGNALS = {
1818
getattr(signal, f"SIG{x}"): x
@@ -27,12 +27,13 @@ class Process:
2727
def __init__(
2828
self,
2929
config: Config,
30-
target: Callable[[list[socket] | None], None],
3130
sockets: list[socket],
3231
) -> None:
33-
self.real_target = target
32+
self.config = config
33+
self._ready = False
3434

3535
self.parent_conn, self.child_conn = Pipe()
36+
self.ready_parent_conn, self.ready_child_conn = Pipe(duplex=False)
3637
self.process = get_subprocess(config, self.target, sockets)
3738

3839
def ping(self, timeout: float = 5) -> bool:
@@ -50,7 +51,7 @@ def always_pong(self) -> None:
5051
while True:
5152
self.pong()
5253

53-
def target(self, sockets: list[socket] | None = None) -> Any: # pragma: no cover
54+
def target(self, sockets: list[socket] | None = None) -> None: # pragma: no cover
5455
if os.name == "nt": # pragma: py-not-win32
5556
# Windows doesn't support SIGTERM, so we use SIGBREAK instead.
5657
# And then we raise SIGTERM when SIGBREAK is received.
@@ -61,7 +62,20 @@ def target(self, sockets: list[socket] | None = None) -> Any: # pragma: no cove
6162
)
6263

6364
threading.Thread(target=self.always_pong, daemon=True).start()
64-
return self.real_target(sockets)
65+
66+
server = Server(config=self.config)
67+
server.on_started = self.notify_started
68+
server.run(sockets)
69+
70+
def notify_started(self) -> None:
71+
self.ready_child_conn.send(b"ready")
72+
73+
@property
74+
def ready(self) -> bool:
75+
if not self._ready and self.ready_parent_conn.poll(0):
76+
self.ready_parent_conn.recv()
77+
self._ready = True
78+
return self._ready
6579

6680
def is_alive(self, timeout: float = 5) -> bool:
6781
if not self.process.is_alive():
@@ -85,6 +99,8 @@ def terminate(self) -> None:
8599

86100
self.parent_conn.close()
87101
self.child_conn.close()
102+
self.ready_parent_conn.close()
103+
self.ready_child_conn.close()
88104

89105
def kill(self) -> None:
90106
# In Windows, the method will call `TerminateProcess` to kill the process.
@@ -104,25 +120,24 @@ class Multiprocess:
104120
def __init__(
105121
self,
106122
config: Config,
107-
target: Callable[[list[socket] | None], None],
108123
sockets: list[socket],
109124
) -> None:
110125
self.config = config
111-
self.target = target
112126
self.sockets = sockets
113127

114128
self.processes_num = config.workers
115129
self.processes: list[Process] = []
116130

117131
self.should_exit = threading.Event()
132+
self.startup_failed = False
118133

119134
self.signal_queue: list[int] = []
120135
for sig in SIGNALS:
121136
signal.signal(sig, lambda sig, frame: self.signal_queue.append(sig))
122137

123138
def init_processes(self) -> None:
124139
for _ in range(self.processes_num):
125-
process = Process(self.config, self.target, self.sockets)
140+
process = Process(self.config, self.sockets)
126141
process.start()
127142
self.processes.append(process)
128143

@@ -138,7 +153,7 @@ def restart_all(self) -> None:
138153
for idx, process in enumerate(self.processes):
139154
process.terminate()
140155
process.join()
141-
new_process = Process(self.config, self.target, self.sockets)
156+
new_process = Process(self.config, self.sockets)
142157
new_process.start()
143158
self.processes[idx] = new_process
144159

@@ -160,6 +175,9 @@ def run(self) -> None:
160175
color_message = "Stopping parent process [{}]".format(click.style(str(os.getpid()), fg="cyan", bold=True))
161176
logger.info(message, extra={"color_message": color_message})
162177

178+
if self.startup_failed:
179+
sys.exit(STARTUP_FAILURE)
180+
163181
def keep_subprocess_alive(self) -> None:
164182
if self.should_exit.is_set():
165183
return # parent process is exiting, no need to keep subprocess alive
@@ -174,8 +192,14 @@ def keep_subprocess_alive(self) -> None:
174192
if self.should_exit.is_set():
175193
return # pragma: full coverage
176194

195+
if not process.ready:
196+
logger.error(f"Child process [{process.pid}] died before startup completed, shutting down.")
197+
self.startup_failed = True
198+
self.should_exit.set()
199+
return
200+
177201
logger.info(f"Child process [{process.pid}] died")
178-
process = Process(self.config, self.target, self.sockets)
202+
process = Process(self.config, self.sockets)
179203
process.start()
180204
self.processes[idx] = process
181205

@@ -208,7 +232,7 @@ def handle_hup(self) -> None: # pragma: py-win32
208232
def handle_ttin(self) -> None: # pragma: py-win32
209233
logger.info("Received SIGTTIN, increasing the number of processes.")
210234
self.processes_num += 1
211-
process = Process(self.config, self.target, self.sockets)
235+
process = Process(self.config, self.sockets)
212236
process.start()
213237
self.processes.append(process)
214238

0 commit comments

Comments
 (0)