plain 0.74.0__py3-none-any.whl → 0.76.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plain/CHANGELOG.md +29 -0
- plain/README.md +1 -1
- plain/chores/README.md +1 -1
- plain/cli/core.py +35 -17
- plain/cli/runtime.py +28 -0
- plain/cli/server.py +143 -0
- plain/server/LICENSE +35 -0
- plain/server/README.md +75 -0
- plain/server/__init__.py +9 -0
- plain/server/app.py +52 -0
- plain/server/arbiter.py +555 -0
- plain/server/config.py +118 -0
- plain/server/errors.py +31 -0
- plain/server/glogging.py +292 -0
- plain/server/http/__init__.py +12 -0
- plain/server/http/body.py +283 -0
- plain/server/http/errors.py +150 -0
- plain/server/http/message.py +399 -0
- plain/server/http/parser.py +69 -0
- plain/server/http/unreader.py +88 -0
- plain/server/http/wsgi.py +421 -0
- plain/server/pidfile.py +91 -0
- plain/server/reloader.py +158 -0
- plain/server/sock.py +219 -0
- plain/server/util.py +380 -0
- plain/server/workers/__init__.py +12 -0
- plain/server/workers/base.py +305 -0
- plain/server/workers/gthread.py +393 -0
- plain/server/workers/sync.py +210 -0
- plain/server/workers/workertmp.py +50 -0
- {plain-0.74.0.dist-info → plain-0.76.0.dist-info}/METADATA +2 -2
- {plain-0.74.0.dist-info → plain-0.76.0.dist-info}/RECORD +35 -9
- {plain-0.74.0.dist-info → plain-0.76.0.dist-info}/WHEEL +0 -0
- {plain-0.74.0.dist-info → plain-0.76.0.dist-info}/entry_points.txt +0 -0
- {plain-0.74.0.dist-info → plain-0.76.0.dist-info}/licenses/LICENSE +0 -0
plain/server/arbiter.py
ADDED
@@ -0,0 +1,555 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
#
|
4
|
+
#
|
5
|
+
# This file is part of gunicorn released under the MIT license.
|
6
|
+
# See the LICENSE for more information.
|
7
|
+
#
|
8
|
+
# Vendored and modified for Plain.
|
9
|
+
import errno
|
10
|
+
import os
|
11
|
+
import random
|
12
|
+
import select
|
13
|
+
import signal
|
14
|
+
import socket
|
15
|
+
import sys
|
16
|
+
import time
|
17
|
+
import traceback
|
18
|
+
from types import FrameType
|
19
|
+
from typing import TYPE_CHECKING, Any
|
20
|
+
|
21
|
+
import plain.runtime
|
22
|
+
|
23
|
+
from . import sock, util
|
24
|
+
from .errors import AppImportError, HaltServer
|
25
|
+
from .pidfile import Pidfile
|
26
|
+
|
27
|
+
if TYPE_CHECKING:
|
28
|
+
from .app import ServerApplication
|
29
|
+
from .config import Config
|
30
|
+
from .glogging import Logger
|
31
|
+
from .workers.base import Worker
|
32
|
+
|
33
|
+
|
34
|
+
class Arbiter:
|
35
|
+
"""
|
36
|
+
Arbiter maintain the workers processes alive. It launches or
|
37
|
+
kills them if needed. It also manages application reloading
|
38
|
+
via SIGHUP.
|
39
|
+
"""
|
40
|
+
|
41
|
+
# A flag indicating if a worker failed to
|
42
|
+
# to boot. If a worker process exist with
|
43
|
+
# this error code, the arbiter will terminate.
|
44
|
+
WORKER_BOOT_ERROR: int = 3
|
45
|
+
|
46
|
+
# A flag indicating if an application failed to be loaded
|
47
|
+
APP_LOAD_ERROR: int = 4
|
48
|
+
|
49
|
+
START_CTX: dict[int | str, Any] = {}
|
50
|
+
|
51
|
+
LISTENERS: list[socket.socket] = []
|
52
|
+
WORKERS: dict[int, Worker] = {}
|
53
|
+
PIPE: list[int] = []
|
54
|
+
|
55
|
+
# I love dynamic languages
|
56
|
+
SIG_QUEUE: list[int] = []
|
57
|
+
SIGNALS: list[int] = [
|
58
|
+
getattr(signal, f"SIG{x}")
|
59
|
+
for x in "HUP QUIT INT TERM TTIN TTOU USR1 USR2 WINCH".split()
|
60
|
+
]
|
61
|
+
SIG_NAMES: dict[int, str] = {
|
62
|
+
getattr(signal, name): name[3:].lower()
|
63
|
+
for name in dir(signal)
|
64
|
+
if name[:3] == "SIG" and name[3] != "_"
|
65
|
+
}
|
66
|
+
|
67
|
+
def __init__(self, app: ServerApplication):
|
68
|
+
os.environ["SERVER_SOFTWARE"] = f"plain/{plain.runtime.__version__}"
|
69
|
+
|
70
|
+
self._num_workers: int | None = None
|
71
|
+
self._last_logged_active_worker_count: int | None = None
|
72
|
+
self.log: Logger | None = None
|
73
|
+
|
74
|
+
self.setup(app)
|
75
|
+
|
76
|
+
self.pidfile: Pidfile | None = None
|
77
|
+
self.worker_age: int = 0
|
78
|
+
|
79
|
+
cwd = util.getcwd()
|
80
|
+
|
81
|
+
args = sys.argv[:]
|
82
|
+
args.insert(0, sys.executable)
|
83
|
+
|
84
|
+
# init start context
|
85
|
+
self.START_CTX = {"args": args, "cwd": cwd, 0: sys.executable}
|
86
|
+
|
87
|
+
def _get_num_workers(self) -> int | None:
|
88
|
+
return self._num_workers
|
89
|
+
|
90
|
+
def _set_num_workers(self, value: int) -> None:
|
91
|
+
self._num_workers = value
|
92
|
+
|
93
|
+
num_workers = property(_get_num_workers, _set_num_workers)
|
94
|
+
|
95
|
+
def setup(self, app: ServerApplication) -> None:
|
96
|
+
self.app: ServerApplication = app
|
97
|
+
assert app.cfg is not None, "Application config must be initialized"
|
98
|
+
self.cfg: Config = app.cfg
|
99
|
+
|
100
|
+
if self.log is None:
|
101
|
+
from .glogging import Logger
|
102
|
+
|
103
|
+
self.log = Logger(self.cfg)
|
104
|
+
|
105
|
+
self.worker_class: type[Worker] = self.cfg.worker_class
|
106
|
+
self.address: str = self.cfg.address
|
107
|
+
self.num_workers = self.cfg.workers
|
108
|
+
self.timeout: int = self.cfg.timeout
|
109
|
+
|
110
|
+
def start(self) -> None:
|
111
|
+
"""\
|
112
|
+
Initialize the arbiter. Start listening and set pidfile if needed.
|
113
|
+
"""
|
114
|
+
self.pid: int = os.getpid()
|
115
|
+
if self.cfg.pidfile is not None:
|
116
|
+
self.pidfile = Pidfile(self.cfg.pidfile)
|
117
|
+
self.pidfile.create(self.pid)
|
118
|
+
|
119
|
+
self.init_signals()
|
120
|
+
|
121
|
+
if not self.LISTENERS:
|
122
|
+
self.LISTENERS = sock.create_sockets(self.cfg, self.log)
|
123
|
+
|
124
|
+
listeners_str = ",".join([str(lnr) for lnr in self.LISTENERS])
|
125
|
+
self.log.info(
|
126
|
+
"Plain server started address=%s pid=%s worker=%s version=%s",
|
127
|
+
listeners_str,
|
128
|
+
self.pid,
|
129
|
+
self.cfg.worker_class_str,
|
130
|
+
plain.runtime.__version__,
|
131
|
+
)
|
132
|
+
|
133
|
+
# check worker class requirements
|
134
|
+
if hasattr(self.worker_class, "check_config"):
|
135
|
+
self.worker_class.check_config(self.cfg, self.log)
|
136
|
+
|
137
|
+
def init_signals(self) -> None:
|
138
|
+
"""\
|
139
|
+
Initialize master signal handling. Most of the signals
|
140
|
+
are queued. Child signals only wake up the master.
|
141
|
+
"""
|
142
|
+
# close old PIPE
|
143
|
+
for p in self.PIPE:
|
144
|
+
os.close(p)
|
145
|
+
|
146
|
+
# initialize the pipe
|
147
|
+
self.PIPE = pair = os.pipe()
|
148
|
+
for p in pair:
|
149
|
+
util.set_non_blocking(p)
|
150
|
+
util.close_on_exec(p)
|
151
|
+
|
152
|
+
self.log.close_on_exec()
|
153
|
+
|
154
|
+
# initialize all signals
|
155
|
+
for s in self.SIGNALS:
|
156
|
+
signal.signal(s, self.signal)
|
157
|
+
signal.signal(signal.SIGCHLD, self.handle_chld)
|
158
|
+
|
159
|
+
def signal(self, sig: int, frame: FrameType | None) -> None:
|
160
|
+
if len(self.SIG_QUEUE) < 5:
|
161
|
+
self.SIG_QUEUE.append(sig)
|
162
|
+
self.wakeup()
|
163
|
+
|
164
|
+
def run(self) -> None:
|
165
|
+
"Main master loop."
|
166
|
+
self.start()
|
167
|
+
|
168
|
+
try:
|
169
|
+
self.manage_workers()
|
170
|
+
|
171
|
+
while True:
|
172
|
+
sig = self.SIG_QUEUE.pop(0) if self.SIG_QUEUE else None
|
173
|
+
if sig is None:
|
174
|
+
self.sleep()
|
175
|
+
self.murder_workers()
|
176
|
+
self.manage_workers()
|
177
|
+
continue
|
178
|
+
|
179
|
+
if sig not in self.SIG_NAMES:
|
180
|
+
self.log.info("Ignoring unknown signal: %s", sig)
|
181
|
+
continue
|
182
|
+
|
183
|
+
signame = self.SIG_NAMES.get(sig)
|
184
|
+
handler = getattr(self, f"handle_{signame}", None)
|
185
|
+
if not handler:
|
186
|
+
self.log.error("Unhandled signal: %s", signame)
|
187
|
+
continue
|
188
|
+
self.log.info("Handling signal: %s", signame)
|
189
|
+
handler()
|
190
|
+
self.wakeup()
|
191
|
+
except (StopIteration, KeyboardInterrupt):
|
192
|
+
self.halt()
|
193
|
+
except HaltServer as inst:
|
194
|
+
self.halt(reason=inst.reason, exit_status=inst.exit_status)
|
195
|
+
except SystemExit:
|
196
|
+
raise
|
197
|
+
except Exception:
|
198
|
+
self.log.error("Unhandled exception in main loop", exc_info=True)
|
199
|
+
self.stop(False)
|
200
|
+
if self.pidfile is not None:
|
201
|
+
self.pidfile.unlink()
|
202
|
+
sys.exit(-1)
|
203
|
+
|
204
|
+
def handle_chld(self, sig: int, frame: FrameType | None) -> None:
|
205
|
+
"SIGCHLD handling"
|
206
|
+
self.reap_workers()
|
207
|
+
self.wakeup()
|
208
|
+
|
209
|
+
def handle_hup(self) -> None:
|
210
|
+
"""\
|
211
|
+
HUP handling.
|
212
|
+
- Reload configuration
|
213
|
+
- Start the new worker processes with a new configuration
|
214
|
+
- Gracefully shutdown the old worker processes
|
215
|
+
"""
|
216
|
+
self.log.info("Hang up: Master")
|
217
|
+
self.reload()
|
218
|
+
|
219
|
+
def handle_term(self) -> None:
|
220
|
+
"SIGTERM handling"
|
221
|
+
raise StopIteration
|
222
|
+
|
223
|
+
def handle_int(self) -> None:
|
224
|
+
"SIGINT handling"
|
225
|
+
self.stop(False)
|
226
|
+
raise StopIteration
|
227
|
+
|
228
|
+
def handle_quit(self) -> None:
|
229
|
+
"SIGQUIT handling"
|
230
|
+
self.stop(False)
|
231
|
+
raise StopIteration
|
232
|
+
|
233
|
+
def handle_ttin(self) -> None:
|
234
|
+
"""\
|
235
|
+
SIGTTIN handling.
|
236
|
+
Increases the number of workers by one.
|
237
|
+
"""
|
238
|
+
self.num_workers += 1
|
239
|
+
self.manage_workers()
|
240
|
+
|
241
|
+
def handle_ttou(self) -> None:
|
242
|
+
"""\
|
243
|
+
SIGTTOU handling.
|
244
|
+
Decreases the number of workers by one.
|
245
|
+
"""
|
246
|
+
if self.num_workers <= 1:
|
247
|
+
return None
|
248
|
+
self.num_workers -= 1
|
249
|
+
self.manage_workers()
|
250
|
+
|
251
|
+
def handle_usr1(self) -> None:
|
252
|
+
"""\
|
253
|
+
SIGUSR1 handling.
|
254
|
+
Kill all workers by sending them a SIGUSR1
|
255
|
+
"""
|
256
|
+
self.log.reopen_files()
|
257
|
+
self.kill_workers(signal.SIGUSR1)
|
258
|
+
|
259
|
+
def handle_usr2(self) -> None:
|
260
|
+
"""SIGUSR2 handling"""
|
261
|
+
# USR2 for graceful restart is not supported
|
262
|
+
self.log.debug("SIGUSR2 ignored")
|
263
|
+
|
264
|
+
def handle_winch(self) -> None:
|
265
|
+
"""SIGWINCH handling"""
|
266
|
+
# SIGWINCH is typically used to gracefully stop workers when running as daemon
|
267
|
+
# Since we don't support daemon mode, just log that it's ignored
|
268
|
+
self.log.debug("SIGWINCH ignored")
|
269
|
+
|
270
|
+
def wakeup(self) -> None:
|
271
|
+
"""\
|
272
|
+
Wake up the arbiter by writing to the PIPE
|
273
|
+
"""
|
274
|
+
try:
|
275
|
+
os.write(self.PIPE[1], b".")
|
276
|
+
except OSError as e:
|
277
|
+
if e.errno not in [errno.EAGAIN, errno.EINTR]:
|
278
|
+
raise
|
279
|
+
|
280
|
+
def halt(self, reason: str | None = None, exit_status: int = 0) -> None:
|
281
|
+
"""halt arbiter"""
|
282
|
+
self.stop()
|
283
|
+
|
284
|
+
log_func = self.log.info if exit_status == 0 else self.log.error
|
285
|
+
log_func("Shutting down: Master")
|
286
|
+
if reason is not None:
|
287
|
+
log_func("Reason: %s", reason)
|
288
|
+
|
289
|
+
if self.pidfile is not None:
|
290
|
+
self.pidfile.unlink()
|
291
|
+
sys.exit(exit_status)
|
292
|
+
|
293
|
+
def sleep(self) -> None:
|
294
|
+
"""\
|
295
|
+
Sleep until PIPE is readable or we timeout.
|
296
|
+
A readable PIPE means a signal occurred.
|
297
|
+
"""
|
298
|
+
try:
|
299
|
+
ready = select.select([self.PIPE[0]], [], [], 1.0)
|
300
|
+
if not ready[0]:
|
301
|
+
return
|
302
|
+
while os.read(self.PIPE[0], 1):
|
303
|
+
pass
|
304
|
+
except OSError as e:
|
305
|
+
# TODO: select.error is a subclass of OSError since Python 3.3.
|
306
|
+
error_number = getattr(e, "errno", e.args[0])
|
307
|
+
if error_number not in [errno.EAGAIN, errno.EINTR]:
|
308
|
+
raise
|
309
|
+
except KeyboardInterrupt:
|
310
|
+
sys.exit()
|
311
|
+
|
312
|
+
def stop(self, graceful: bool = True) -> None:
|
313
|
+
"""\
|
314
|
+
Stop workers
|
315
|
+
|
316
|
+
:attr graceful: boolean, If True (the default) workers will be
|
317
|
+
killed gracefully (ie. trying to wait for the current connection)
|
318
|
+
"""
|
319
|
+
sock.close_sockets(self.LISTENERS, unlink=True)
|
320
|
+
|
321
|
+
self.LISTENERS = []
|
322
|
+
sig = signal.SIGTERM
|
323
|
+
if not graceful:
|
324
|
+
sig = signal.SIGQUIT
|
325
|
+
limit = time.time() + self.cfg.graceful_timeout
|
326
|
+
# instruct the workers to exit
|
327
|
+
self.kill_workers(sig)
|
328
|
+
# wait until the graceful timeout
|
329
|
+
while self.WORKERS and time.time() < limit:
|
330
|
+
time.sleep(0.1)
|
331
|
+
|
332
|
+
self.kill_workers(signal.SIGKILL)
|
333
|
+
|
334
|
+
def reload(self) -> None:
|
335
|
+
old_address = self.cfg.address
|
336
|
+
|
337
|
+
self.setup(self.app)
|
338
|
+
|
339
|
+
# reopen log files
|
340
|
+
self.log.reopen_files()
|
341
|
+
|
342
|
+
# do we need to change listener ?
|
343
|
+
if old_address != self.cfg.address:
|
344
|
+
# close all listeners
|
345
|
+
for lnr in self.LISTENERS:
|
346
|
+
lnr.close()
|
347
|
+
# init new listeners
|
348
|
+
self.LISTENERS = sock.create_sockets(self.cfg, self.log)
|
349
|
+
listeners_str = ",".join([str(lnr) for lnr in self.LISTENERS])
|
350
|
+
self.log.info("Listening at: %s", listeners_str)
|
351
|
+
|
352
|
+
# unlink pidfile
|
353
|
+
if self.pidfile is not None:
|
354
|
+
self.pidfile.unlink()
|
355
|
+
|
356
|
+
# create new pidfile
|
357
|
+
if self.cfg.pidfile is not None:
|
358
|
+
self.pidfile = Pidfile(self.cfg.pidfile)
|
359
|
+
self.pidfile.create(self.pid)
|
360
|
+
|
361
|
+
# spawn new workers
|
362
|
+
for _ in range(self.cfg.workers):
|
363
|
+
self.spawn_worker()
|
364
|
+
|
365
|
+
# manage workers
|
366
|
+
self.manage_workers()
|
367
|
+
|
368
|
+
def murder_workers(self) -> None:
|
369
|
+
"""\
|
370
|
+
Kill unused/idle workers
|
371
|
+
"""
|
372
|
+
if not self.timeout:
|
373
|
+
return None
|
374
|
+
workers = list(self.WORKERS.items())
|
375
|
+
for pid, worker in workers:
|
376
|
+
try:
|
377
|
+
if time.monotonic() - worker.tmp.last_update() <= self.timeout:
|
378
|
+
continue
|
379
|
+
except (OSError, ValueError):
|
380
|
+
continue
|
381
|
+
|
382
|
+
if not worker.aborted:
|
383
|
+
self.log.critical("WORKER TIMEOUT (pid:%s)", pid)
|
384
|
+
worker.aborted = True
|
385
|
+
self.kill_worker(pid, signal.SIGABRT)
|
386
|
+
else:
|
387
|
+
self.kill_worker(pid, signal.SIGKILL)
|
388
|
+
|
389
|
+
def reap_workers(self) -> None:
|
390
|
+
"""\
|
391
|
+
Reap workers to avoid zombie processes
|
392
|
+
"""
|
393
|
+
try:
|
394
|
+
while True:
|
395
|
+
wpid, status = os.waitpid(-1, os.WNOHANG)
|
396
|
+
if not wpid:
|
397
|
+
break
|
398
|
+
|
399
|
+
# A worker was terminated. If the termination reason was
|
400
|
+
# that it could not boot, we'll shut it down to avoid
|
401
|
+
# infinite start/stop cycles.
|
402
|
+
exitcode = status >> 8
|
403
|
+
if exitcode != 0:
|
404
|
+
self.log.error(
|
405
|
+
"Worker (pid:%s) exited with code %s", wpid, exitcode
|
406
|
+
)
|
407
|
+
if exitcode == self.WORKER_BOOT_ERROR:
|
408
|
+
reason = "Worker failed to boot."
|
409
|
+
raise HaltServer(reason, self.WORKER_BOOT_ERROR)
|
410
|
+
if exitcode == self.APP_LOAD_ERROR:
|
411
|
+
reason = "App failed to load."
|
412
|
+
raise HaltServer(reason, self.APP_LOAD_ERROR)
|
413
|
+
|
414
|
+
if exitcode > 0:
|
415
|
+
# If the exit code of the worker is greater than 0,
|
416
|
+
# let the user know.
|
417
|
+
self.log.error(
|
418
|
+
"Worker (pid:%s) exited with code %s.", wpid, exitcode
|
419
|
+
)
|
420
|
+
elif status > 0:
|
421
|
+
# If the exit code of the worker is 0 and the status
|
422
|
+
# is greater than 0, then it was most likely killed
|
423
|
+
# via a signal.
|
424
|
+
try:
|
425
|
+
sig_name = signal.Signals(status).name
|
426
|
+
except ValueError:
|
427
|
+
sig_name = f"code {status}"
|
428
|
+
msg = f"Worker (pid:{wpid}) was sent {sig_name}!"
|
429
|
+
|
430
|
+
# Additional hint for SIGKILL
|
431
|
+
if status == signal.SIGKILL:
|
432
|
+
msg += " Perhaps out of memory?"
|
433
|
+
self.log.error(msg)
|
434
|
+
|
435
|
+
worker = self.WORKERS.pop(wpid, None)
|
436
|
+
if not worker:
|
437
|
+
continue
|
438
|
+
worker.tmp.close()
|
439
|
+
except OSError as e:
|
440
|
+
if e.errno != errno.ECHILD:
|
441
|
+
raise
|
442
|
+
|
443
|
+
def manage_workers(self) -> None:
|
444
|
+
"""\
|
445
|
+
Maintain the number of workers by spawning or killing
|
446
|
+
as required.
|
447
|
+
"""
|
448
|
+
if len(self.WORKERS) < self.num_workers:
|
449
|
+
self.spawn_workers()
|
450
|
+
|
451
|
+
workers = self.WORKERS.items()
|
452
|
+
workers = sorted(workers, key=lambda w: w[1].age)
|
453
|
+
while len(workers) > self.num_workers:
|
454
|
+
(pid, _) = workers.pop(0)
|
455
|
+
self.kill_worker(pid, signal.SIGTERM)
|
456
|
+
|
457
|
+
active_worker_count = len(workers)
|
458
|
+
if self._last_logged_active_worker_count != active_worker_count:
|
459
|
+
self._last_logged_active_worker_count = active_worker_count
|
460
|
+
self.log.debug(
|
461
|
+
f"{active_worker_count} workers",
|
462
|
+
extra={
|
463
|
+
"metric": "plain.server.workers",
|
464
|
+
"value": active_worker_count,
|
465
|
+
"mtype": "gauge",
|
466
|
+
},
|
467
|
+
)
|
468
|
+
|
469
|
+
def spawn_worker(self) -> int:
|
470
|
+
self.worker_age += 1
|
471
|
+
worker = self.worker_class(
|
472
|
+
self.worker_age,
|
473
|
+
self.pid,
|
474
|
+
self.LISTENERS,
|
475
|
+
self.app,
|
476
|
+
self.timeout / 2.0,
|
477
|
+
self.cfg,
|
478
|
+
self.log,
|
479
|
+
)
|
480
|
+
pid = os.fork()
|
481
|
+
if pid != 0:
|
482
|
+
worker.pid = pid
|
483
|
+
self.WORKERS[pid] = worker
|
484
|
+
return pid
|
485
|
+
|
486
|
+
# Do not inherit the temporary files of other workers
|
487
|
+
for sibling in self.WORKERS.values():
|
488
|
+
sibling.tmp.close()
|
489
|
+
|
490
|
+
# Process Child
|
491
|
+
worker.pid = os.getpid()
|
492
|
+
try:
|
493
|
+
self.log.info("Plain server worker started pid=%s", worker.pid)
|
494
|
+
worker.init_process()
|
495
|
+
sys.exit(0)
|
496
|
+
except SystemExit:
|
497
|
+
raise
|
498
|
+
except AppImportError as e:
|
499
|
+
self.log.debug("Exception while loading the application", exc_info=True)
|
500
|
+
print(f"{e}", file=sys.stderr)
|
501
|
+
sys.stderr.flush()
|
502
|
+
sys.exit(self.APP_LOAD_ERROR)
|
503
|
+
except Exception:
|
504
|
+
self.log.exception("Exception in worker process")
|
505
|
+
if not worker.booted:
|
506
|
+
sys.exit(self.WORKER_BOOT_ERROR)
|
507
|
+
sys.exit(-1)
|
508
|
+
finally:
|
509
|
+
self.log.info("Worker exiting (pid: %s)", worker.pid)
|
510
|
+
try:
|
511
|
+
worker.tmp.close()
|
512
|
+
except Exception:
|
513
|
+
self.log.warning(
|
514
|
+
"Exception during worker exit:\n%s", traceback.format_exc()
|
515
|
+
)
|
516
|
+
|
517
|
+
def spawn_workers(self) -> None:
|
518
|
+
"""\
|
519
|
+
Spawn new workers as needed.
|
520
|
+
|
521
|
+
This is where a worker process leaves the main loop
|
522
|
+
of the master process.
|
523
|
+
"""
|
524
|
+
|
525
|
+
for _ in range(self.num_workers - len(self.WORKERS)):
|
526
|
+
self.spawn_worker()
|
527
|
+
time.sleep(0.1 * random.random())
|
528
|
+
|
529
|
+
def kill_workers(self, sig: int) -> None:
|
530
|
+
"""\
|
531
|
+
Kill all workers with the signal `sig`
|
532
|
+
:attr sig: `signal.SIG*` value
|
533
|
+
"""
|
534
|
+
worker_pids = list(self.WORKERS.keys())
|
535
|
+
for pid in worker_pids:
|
536
|
+
self.kill_worker(pid, sig)
|
537
|
+
|
538
|
+
def kill_worker(self, pid: int, sig: int) -> None:
|
539
|
+
"""\
|
540
|
+
Kill a worker
|
541
|
+
|
542
|
+
:attr pid: int, worker pid
|
543
|
+
:attr sig: `signal.SIG*` value
|
544
|
+
"""
|
545
|
+
try:
|
546
|
+
os.kill(pid, sig)
|
547
|
+
except OSError as e:
|
548
|
+
if e.errno == errno.ESRCH:
|
549
|
+
try:
|
550
|
+
worker = self.WORKERS.pop(pid)
|
551
|
+
worker.tmp.close()
|
552
|
+
return None
|
553
|
+
except (KeyError, OSError):
|
554
|
+
return None
|
555
|
+
raise
|
plain/server/config.py
ADDED
@@ -0,0 +1,118 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
#
|
4
|
+
#
|
5
|
+
# This file is part of gunicorn released under the MIT license.
|
6
|
+
# See the LICENSE for more information.
|
7
|
+
#
|
8
|
+
# Vendored and modified for Plain.
|
9
|
+
import os
|
10
|
+
from dataclasses import dataclass
|
11
|
+
|
12
|
+
from . import util
|
13
|
+
|
14
|
+
|
15
|
+
@dataclass
|
16
|
+
class Config:
|
17
|
+
"""Plain server configuration.
|
18
|
+
|
19
|
+
All configuration values are required and provided by the CLI.
|
20
|
+
Defaults are defined in the CLI layer, not here.
|
21
|
+
"""
|
22
|
+
|
23
|
+
# Core settings (from CLI)
|
24
|
+
bind: list[str]
|
25
|
+
workers: int
|
26
|
+
threads: int
|
27
|
+
timeout: int
|
28
|
+
max_requests: int
|
29
|
+
reload: bool
|
30
|
+
reload_extra_files: list[str]
|
31
|
+
pidfile: str | None
|
32
|
+
certfile: str | None
|
33
|
+
keyfile: str | None
|
34
|
+
loglevel: str
|
35
|
+
accesslog: str
|
36
|
+
errorlog: str
|
37
|
+
log_format: str
|
38
|
+
access_log_format: str
|
39
|
+
|
40
|
+
@property
|
41
|
+
def worker_class_str(self) -> str:
|
42
|
+
# Auto-select based on threads
|
43
|
+
if self.threads > 1:
|
44
|
+
return "gthread"
|
45
|
+
return "sync"
|
46
|
+
|
47
|
+
@property
|
48
|
+
def worker_class(self) -> type:
|
49
|
+
# Auto-select based on threads
|
50
|
+
if self.threads > 1:
|
51
|
+
uri = "plain.server.workers.gthread.ThreadWorker"
|
52
|
+
else:
|
53
|
+
uri = "plain.server.workers.sync.SyncWorker"
|
54
|
+
|
55
|
+
worker_class = util.load_class(uri)
|
56
|
+
if hasattr(worker_class, "setup"):
|
57
|
+
worker_class.setup() # type: ignore[call-non-callable] # hasattr check doesn't narrow type
|
58
|
+
return worker_class
|
59
|
+
|
60
|
+
@property
|
61
|
+
def address(self) -> list[tuple[str, int] | str]:
|
62
|
+
return [util.parse_address(util.bytes_to_str(bind)) for bind in self.bind]
|
63
|
+
|
64
|
+
@property
|
65
|
+
def is_ssl(self) -> bool:
|
66
|
+
return self.certfile is not None or self.keyfile is not None
|
67
|
+
|
68
|
+
@property
|
69
|
+
def sendfile(self) -> bool:
|
70
|
+
if "SENDFILE" in os.environ:
|
71
|
+
sendfile = os.environ["SENDFILE"].lower()
|
72
|
+
return sendfile in ["y", "1", "yes", "true"]
|
73
|
+
|
74
|
+
return True
|
75
|
+
|
76
|
+
@property
|
77
|
+
def graceful_timeout(self) -> int:
|
78
|
+
"""Timeout for graceful worker shutdown in seconds."""
|
79
|
+
return 30
|
80
|
+
|
81
|
+
@property
|
82
|
+
def forwarded_allow_ips(self) -> list[str]:
|
83
|
+
"""
|
84
|
+
Trusted proxy IPs allowed to set secure headers.
|
85
|
+
Default: ['127.0.0.1', '::1'] (localhost only)
|
86
|
+
Can be overridden via FORWARDED_ALLOW_IPS environment variable.
|
87
|
+
"""
|
88
|
+
val = os.environ.get("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
|
89
|
+
return [v.strip() for v in val.split(",") if v]
|
90
|
+
|
91
|
+
@property
|
92
|
+
def secure_scheme_headers(self) -> dict[str, str]:
|
93
|
+
"""
|
94
|
+
Headers that indicate HTTPS when set by a trusted proxy.
|
95
|
+
Default headers: X-FORWARDED-PROTOCOL, X-FORWARDED-PROTO, X-FORWARDED-SSL
|
96
|
+
"""
|
97
|
+
return {
|
98
|
+
"X-FORWARDED-PROTOCOL": "ssl",
|
99
|
+
"X-FORWARDED-PROTO": "https",
|
100
|
+
"X-FORWARDED-SSL": "on",
|
101
|
+
}
|
102
|
+
|
103
|
+
@property
|
104
|
+
def forwarder_headers(self) -> list[str]:
|
105
|
+
"""
|
106
|
+
Header names that proxies can use to override WSGI environment.
|
107
|
+
Default: ['SCRIPT_NAME', 'PATH_INFO']
|
108
|
+
"""
|
109
|
+
return ["SCRIPT_NAME", "PATH_INFO"]
|
110
|
+
|
111
|
+
@property
|
112
|
+
def header_map(self) -> str:
|
113
|
+
"""
|
114
|
+
How to handle header names with underscores.
|
115
|
+
Default: 'drop' (silently drop ambiguous headers)
|
116
|
+
Options: 'drop', 'refuse', 'dangerous'
|
117
|
+
"""
|
118
|
+
return "drop"
|
plain/server/errors.py
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
#
|
2
|
+
#
|
3
|
+
# This file is part of gunicorn released under the MIT license.
|
4
|
+
# See the LICENSE for more information.
|
5
|
+
#
|
6
|
+
# Vendored and modified for Plain.
|
7
|
+
|
8
|
+
# We don't need to call super() in __init__ methods of our
|
9
|
+
# BaseException and Exception classes because we also define
|
10
|
+
# our own __str__ methods so there is no need to pass 'message'
|
11
|
+
# to the base class to get a meaningful output from 'str(exc)'.
|
12
|
+
# pylint: disable=super-init-not-called
|
13
|
+
|
14
|
+
|
15
|
+
# we inherit from BaseException here to make sure to not be caught
|
16
|
+
# at application level
|
17
|
+
class HaltServer(BaseException):
|
18
|
+
def __init__(self, reason: str, exit_status: int = 1) -> None:
|
19
|
+
self.reason = reason
|
20
|
+
self.exit_status = exit_status
|
21
|
+
|
22
|
+
def __str__(self) -> str:
|
23
|
+
return f"<HaltServer {self.reason!r} {self.exit_status}>"
|
24
|
+
|
25
|
+
|
26
|
+
class ConfigError(Exception):
|
27
|
+
"""Exception raised on config error"""
|
28
|
+
|
29
|
+
|
30
|
+
class AppImportError(Exception):
|
31
|
+
"""Exception raised when loading an application"""
|