-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathruntime.py
More file actions
1550 lines (1377 loc) · 60.2 KB
/
Copy pathruntime.py
File metadata and controls
1550 lines (1377 loc) · 60.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""The W4VE guardian: it owns processes and survives everything else.
RFC-0001 in code. The guardian is small on purpose. It does not parse chat, it
does not load plugins and it does not talk to the network: it starts processes,
watches them, relays their console and reports the truth about them. Everything
clever lives somewhere else and is allowed to crash without taking the server
down with it.
Nothing here imports anything outside the standard library, and nothing here
needs Python newer than 3.9, because that is what the mirror runs.
"""
import errno
import fcntl
import hashlib
import json
import os
import re
import shlex
import signal
import socket
import subprocess
import tempfile
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
try:
import rcon as rcon_module
except ImportError: # a bare runtime.py still works, minus the fallback
rcon_module = None
SCHEMA = 1
# How long we wait for a clean stop before we start escalating, and how long
# between each escalation. Saving a big world takes a while, and killing a
# server mid-save is how chunks get lost.
STOP_GRACE = 90
TERM_GRACE = 10
# A service is not a world. The long grace exists so Minecraft can save what
# people built; an image server or a bridge daemon has nothing to lose by
# being asked to leave now.
SERVICE_GRACE = 10
# How long a service waits for what it declared it comes after. Long enough
# for a daemon to bind a port, short enough that a boot never hangs on it.
DEPENDENCY_WAIT = 30
# Three deaths this close together and we stop trying. Retrying forever against
# a corrupt world is how an incident becomes data loss.
CRASH_WINDOW = 600
CRASH_LIMIT = 3
# A timer that runs longer than this is not a timer any more, and letting it
# pile up would eventually be a machine full of the same script.
TIMER_TIMEOUT = 300
# How often the disk is looked at. Storage fills up over days, not seconds.
STORAGE_EVERY = 300
CONSOLE_CAP = 8 * 1024 * 1024
def now():
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# ------------------------------------------------------------ process identity
def starttime_of(pid):
"""Return the boot-relative start time of a pid, or None.
A pid is not an identity: the kernel hands them out again. The pair
(pid, starttime) is one, because two processes cannot start at the same
tick with the same number.
"""
try:
stat = Path("/proc/%d/stat" % pid).read_text()
except (OSError, ValueError):
return None
# The command name sits in parentheses and may contain spaces, so the
# fields only line up after the LAST closing paren.
tail = stat[stat.rfind(")") + 1:].split()
try:
return int(tail[19])
except (IndexError, ValueError):
return None
def alive(pid, starttime=None):
"""Is this exact process still running?"""
if not pid:
return False
try:
os.kill(pid, 0)
except OSError as exc:
if exc.errno == errno.ESRCH:
return False
if exc.errno != errno.EPERM:
return False
if starttime is None:
return True
seen = starttime_of(pid)
# Without /proc (not Linux) we cannot tell a recycled pid from the real
# one. Saying "alive" there is the honest answer, not a guarantee.
return seen is None or seen == starttime
# -------------------------------------------------------------------- handlers
READY = re.compile(r'Done \([\d.]+s\)! For help')
STOPPING = re.compile(r'Stopping (?:the )?server')
class Handler:
"""What W4VE needs to know about one flavour of server."""
name = "vanilla"
packages = "none"
companion = False
note = "vanilla has nowhere to put mods or plugins."
def ready(self, line):
return bool(READY.search(line))
def stopping(self, line):
return bool(STOPPING.search(line))
def stop_command(self):
return "stop"
def capabilities(self):
caps = {
"flavor": self.name,
"managed": True,
"packages": self.packages,
"companion": self.companion,
"rcon": True,
}
if self.note:
caps["note"] = self.note
return caps
class Fabric(Handler):
name = "fabric"
packages = "mods"
companion = True
note = ""
class Paper(Handler):
"""Managed, with reduced capabilities, decided on 19 August 2026.
Leaving Paper merely observed would keep MCDR alive forever on the fifth
MineWave server, and killing that dependency is the point of the project.
So W4VE owns its process like any other, and says out loud the two things
it cannot do there.
"""
name = "paper"
packages = "none"
companion = False
note = "Bukkit plugins are not installed from the W4VE catalog."
HANDLERS = {"fabric": Fabric, "paper": Paper, "vanilla": Handler}
def detect_flavor(game_dir):
"""Guess the flavour from what is on disk, never from the process name."""
game_dir = Path(game_dir)
if (game_dir / "mods").is_dir():
return "fabric"
jars = [p.name.lower() for p in game_dir.glob("*.jar")]
if any("paper" in n or "spigot" in n or "purpur" in n for n in jars):
return "paper"
if (game_dir / "plugins").is_dir():
return "paper"
return "vanilla"
def handler_for(flavor):
return HANDLERS.get(flavor, Handler)()
# ----------------------------------------------------------------- supervision
class Watched:
"""One process the guardian owns, with an explicit state.
The Minecraft server is the first one. The second halves that real pieces
ship (an image service, a scanner, a bridge) use this same contract, which
is the whole point: a cron nobody watches is how the ShapeBoard scan sat
dead for three days.
"""
def __init__(self, name, command, cwd, guardian, handler=None,
restart="never", stop_grace=STOP_GRACE, health=None,
health_every=30, watches=(), env=None, after=()):
self.name = name
self.command = command
self.cwd = Path(cwd)
self.guardian = guardian
self.handler = handler
self.restart = restart
self.stop_grace = stop_grace
self.health = health # a URL, for things with no console
self.health_every = health_every
# Lines a piece asked us to look for, and what to do about them.
self.watches = [dict(w) for w in watches]
# Added to the environment this process is started with, never
# replacing it: a service that lost PATH is a service that cannot find
# its own interpreter.
self.env = dict(env or {})
# Names this one wants up before it starts. Not a graph to be proud
# of: a straight list, resolved in the order things were declared.
self.after = [str(name) for name in (after or [])]
self.asked_to_come_back = False
self.proc = None
self.pid = None
self.starttime = None
self.owned = False
self.state = "stopped"
self.ready = None # None means "we do not know yet"
self.started_at = None
self.exit_code = None
self.asked_to_stop = False
self.deaths = []
self.give_up = False
self.lock = threading.Lock()
# ---------------------------------------------------------------- helpers
@property
def console_path(self):
return self.guardian.run_dir / ("%s.console.log" % self.name)
def _become(self, state, why=""):
old, self.state = self.state, state
if old != state:
self.guardian.log("%s: %s -> %s%s" % (self.name, old, state,
" (%s)" % why if why else ""))
self.guardian.snapshot()
self.guardian.plugins_state(self.name, state, self.ready,
self.exit_code)
def status(self):
running = alive(self.pid, self.starttime)
info = {
"name": self.name,
"state": self.state,
"ready": self.ready,
# A pid is only worth printing while it means something. Showing
# the number of a process that already died is how people go and
# kill whatever inherited it.
"pid": self.pid if running else None,
"last_pid": self.pid,
"owned": self.owned and running,
"started_at": self.started_at,
"exit_code": self.exit_code,
"give_up": self.give_up,
}
if self.started_at and running:
info["uptime"] = int(time.time() - self.started_at)
if self.handler:
info["capabilities"] = self.handler.capabilities()
if running and not self.owned and rcon_module is not None and self.handler:
creds, _ = rcon_module.credentials_from(self.cwd)
info["rcon"] = bool(creds)
return info
# ---------------------------------------------------------------- running
def start(self):
with self.lock:
if alive(self.pid, self.starttime):
return False, "%s is already running (pid %s)" % (self.name, self.pid)
self.asked_to_stop = False
self.exit_code = None
self.ready = None
self._become("starting", "start requested")
# No shell in the middle. A `/bin/sh -c` steals the signals meant
# for the server and lies about the process tree, which is exactly
# the shape MCDR has today.
environment = None
if self.env:
environment = dict(os.environ)
environment.update(self.env)
self.proc = subprocess.Popen(
self.command,
cwd=str(self.cwd),
env=environment,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1,
universal_newlines=True,
start_new_session=True, # its own process group, on purpose
)
self.pid = self.proc.pid
self.starttime = starttime_of(self.pid)
self.owned = True
self.started_at = time.time()
threading.Thread(target=self._read_console, daemon=True).start()
threading.Thread(target=self._reap, daemon=True).start()
if self.handler is None:
# Nothing to read a start-up line out of. A service is running
# the moment it is up; whether it is READY is what the health
# check answers, and without one we say we do not know.
self._become("running", "no console to wait on")
if self.health:
threading.Thread(target=self._poll_health,
daemon=True).start()
self.guardian.snapshot()
return True, "%s started (pid %d)" % (self.name, self.pid)
def adopt(self, pid, starttime, started_at=None):
"""Take note of a process we did not start.
We can watch it and we can kill it, but we do not hold its stdin, so
console commands are not ours. Saying `owned: false` is the point: a
runtime that pretends to be in control is worse than one that admits
it is not.
"""
self.pid = pid
self.starttime = starttime
self.owned = False
self.started_at = started_at
self.ready = None
self._become("running", "adopted a process we did not start")
threading.Thread(target=self._watch_foreign, daemon=True).start()
def send(self, text):
if not alive(self.pid, self.starttime):
return False, "%s is not running" % self.name
if not self.owned or not self.proc or not self.proc.stdin:
# No stdin of ours, which is the normal state for a server we only
# adopted. RFC-0001 says the fallback is RCON, so use it instead of
# telling the caller to go and do it themselves.
return self.send_over_rcon(text)
try:
self.proc.stdin.write(text.rstrip("\n") + "\n")
self.proc.stdin.flush()
except (BrokenPipeError, ValueError) as exc:
return False, "console is gone: %s" % exc
return True, "sent"
def rcon(self):
"""A logged-in RCON connection to this server, or why there is none."""
if rcon_module is None:
return None, "this w4ve has no rcon.py next to it"
if self.handler is None:
return None, "%s is a service, not a server: it has no RCON" % self.name
creds, why = rcon_module.credentials_from(self.cwd)
if not creds:
return None, why
try:
return rcon_module.Rcon(creds["host"], creds["port"],
creds["password"]).connect(), ""
except rcon_module.RconError as exc:
return None, str(exc)
def send_over_rcon(self, text):
console, why = self.rcon()
if console is None:
return False, "no console and no RCON: %s" % why
try:
answer = console.command(text.strip())
except rcon_module.RconError as exc:
return False, "RCON: %s" % exc
finally:
console.close()
self.guardian.log("%s: sent %r over RCON" % (self.name, text.strip()))
return True, answer.strip() or "sent over RCON"
def stop(self, grace=None):
if not alive(self.pid, self.starttime):
self._become("stopped", "nothing to stop")
return True, "%s was not running" % self.name
grace = self.stop_grace if grace is None else grace
self.asked_to_stop = True
self._become("stopping", "stop requested")
if self.owned and self.handler is not None:
self.send(self.handler.stop_command())
elif self.owned:
# ⚠️ A service has no console to type `stop` into. Writing the word
# into its stdin does nothing at all, and then the guardian sat
# through the full ninety second grace waiting for an answer that
# was never coming: `w4ve stop <service>` looked like it had hung.
# A signal is how you ask a service to leave.
self._signal(signal.SIGTERM, "a service is asked with a signal")
else:
# An adopted server still knows how to save itself: ask it over
# RCON, and only reach for signals when there is no answer.
asked, message = self.send_over_rcon(
self.handler.stop_command() if self.handler else "stop")
if not asked:
self.guardian.log("%s: %s" % (self.name, message))
self._signal(signal.SIGTERM, "adopted process, no console, no RCON")
if self._wait_for_exit(grace):
return True, "%s stopped" % self.name
self.guardian.log("%s: did not stop in %ds, escalating" % (self.name, grace))
self._signal(signal.SIGTERM, "grace expired")
if self._wait_for_exit(TERM_GRACE):
return True, "%s stopped after SIGTERM" % self.name
self._signal(signal.SIGKILL, "SIGTERM ignored")
self._wait_for_exit(5)
self._become("killed", "had to be killed")
return True, "%s killed" % self.name
def _signal(self, sig, why):
"""Signal the whole process group, not just the process.
The server spawns children of its own; a signal to the group is what
actually reaches them.
"""
self.guardian.log("%s: sending %s (%s)" % (self.name, sig.name, why))
try:
os.killpg(os.getpgid(self.pid), sig)
except OSError:
try:
os.kill(self.pid, sig)
except OSError:
pass
def _wait_for_exit(self, seconds):
deadline = time.time() + seconds
while time.time() < deadline:
if not alive(self.pid, self.starttime):
return True
time.sleep(0.2)
return not alive(self.pid, self.starttime)
# ------------------------------------------------------------- background
def _read_console(self):
console = open(str(self.console_path), "a", buffering=1)
try:
for line in self.proc.stdout:
line = line.rstrip("\n")
if console.tell() > CONSOLE_CAP:
console.close()
self.console_path.replace(
self.console_path.with_suffix(".log.1"))
console = open(str(self.console_path), "a", buffering=1)
console.write(line + "\n")
self.guardian.broadcast(self.name, line)
self._check_watches(line)
# Handed over, never called here: plugins run on their own
# thread so a slow one cannot delay the guardian noticing that
# the server is ready, or that it died.
self.guardian.feed_plugins(self.name, line)
if self.handler:
if self.state == "starting" and self.handler.ready(line):
self.ready = True
self._become("running", "server says it is ready")
elif self.handler.stopping(line) and not self.asked_to_stop:
self.asked_to_stop = True
self._become("stopping", "the server decided to stop")
except (ValueError, OSError):
pass
finally:
console.close()
def _check_watches(self, line):
"""Act on the lines a piece told us to look for.
A piece that stops the server on purpose (RegionCast copies region
files while it is down) can ask for it to come back, instead of the
operator installing a plugin whose whole job is that one line. The
restart is not automatic policy, it is a request from the piece that is
doing the work, and it is written to the journal either way.
"""
for watch in self.watches:
try:
if not re.search(watch["on"], line):
continue
except re.error:
continue
why = watch.get("why") or watch.get("name", "a watch")
self.guardian.log("%s: matched %r (%s)"
% (self.name, watch.get("name", "?"), why))
if watch.get("do") == "restart":
# The stop that follows is expected, so it is not a crash and
# does not count against the crash-loop budget.
self.asked_to_stop = True
self.asked_to_come_back = True
def _check_health(self):
"""Is this service answering? True, False, whichever way it is asked.
Two ways because services are not all web servers. ChatBridge's daemon
speaks its own protocol over a TCP port and has no health URL at all,
so asking it for one meant its readiness stayed unknown forever, and a
service whose readiness is always unknown is a service nobody can wait
for.
"""
if str(self.health).startswith("tcp://"):
where = str(self.health)[len("tcp://"):]
host, _, port = where.rpartition(":")
try:
with socket.create_connection((host or "127.0.0.1",
int(port)), timeout=5):
return True
except (OSError, ValueError):
return False
import urllib.error
import urllib.request
try:
with urllib.request.urlopen(self.health, timeout=5) as response:
return 200 <= response.status < 400
except (urllib.error.URLError, OSError, ValueError):
return False
def _poll_health(self):
# A service is allowed to take a moment to bind its port. Calling it
# unhealthy while it is still starting is noise, so the first few
# failures only mean "not yet".
tries = 0
while alive(self.pid, self.starttime) and not self.guardian.stopping.is_set():
tries += 1
healthy = self._check_health()
if not healthy and self.ready is None and tries < 6:
time.sleep(2)
continue
if healthy != self.ready:
self.ready = healthy
self.guardian.plugins_state(self.name, self.state, self.ready)
self.guardian.log("%s: health %s (%s)"
% (self.name, "ok" if healthy else "failing",
self.health))
self.guardian.snapshot()
# ⚠️ A TCP check is asked once and then left alone. Opening a
# port and closing it without saying anything is not a question,
# it is a connection the other end has to explain: ChatBridge's
# daemon logged `Failed reading client's login packet` every
# thirty seconds, which is two thousand nine hundred false errors
# a day in somebody else's log. An open port answers "it started",
# not "it is well", and that answer does not change on its own.
# An HTTP health check is a real question and keeps being asked.
if healthy and str(self.health).startswith("tcp://"):
self.guardian.log("%s: port open, not asking again "
"(a TCP check says started, not healthy)"
% self.name)
return
# Checks come quickly until the first success, because a service
# that is up in a second should not read as unknown for half a
# minute.
time.sleep(self.health_every if self.ready else 2)
def _reap(self):
code = self.proc.wait()
self.exit_code = code
self.ready = False
if self.asked_to_come_back:
self.asked_to_come_back = False
self._become("stopped", "exit %d, a piece asked it to come back" % code)
time.sleep(1)
ok, message = self.start()
self.guardian.log(message)
elif self.asked_to_stop or self.state in ("stopping", "killed"):
self._become("stopped", "exit %d" % code)
else:
self._note_death()
self._become("crashed", "exit %d, nobody asked for that" % code)
self._maybe_restart()
def _watch_foreign(self):
"""Adopted processes have no console, so all we can do is notice."""
while alive(self.pid, self.starttime):
time.sleep(1)
if self.asked_to_stop or self.state == "stopping":
self._become("stopped", "adopted process exited")
else:
self._note_death()
self._become("crashed", "adopted process died")
def _note_death(self):
cut = time.time() - CRASH_WINDOW
self.deaths = [t for t in self.deaths if t > cut] + [time.time()]
if len(self.deaths) >= CRASH_LIMIT:
self.give_up = True
self.guardian.log(
"%s: %d deaths in %d minutes, not restarting it again"
% (self.name, len(self.deaths), CRASH_WINDOW // 60))
def _maybe_restart(self):
if self.restart == "never" or self.give_up:
return
self.guardian.log("%s: restarting (policy %s)" % (self.name, self.restart))
time.sleep(2)
self.start()
# -------------------------------------------------------------------- guardian
# A Unix socket path lives in a fixed size field in the kernel, 108 bytes with
# the trailing zero, and a server folder nested deep enough goes over it. Before
# this was handled the bind blew up in a thread and the guardian ran on with no
# control socket at all: alive, working, and unreachable. Anything that walks a
# path length limit has to fail loudly or route around it.
SOCK_MAX = 100
# --------------------------------------------------------------------- disk
def parse_size(text):
"""`2G` -> bytes. Duplicated from piecesettings on purpose.
The runtime has to work on a machine where only `w4ve.py` and `runtime.py`
were copied, so it does not get to depend on the settings module for two
lines of arithmetic.
"""
raw = str(text).strip().lower().replace("b", "")
units = {"k": 1024, "m": 1024 ** 2, "g": 1024 ** 3, "t": 1024 ** 4}
unit = units.get(raw[-1:]) if raw else None
try:
return int(float(raw[:-1] if unit else raw) * (unit or 1))
except ValueError:
return 0
def parse_time(text):
"""`7d` -> seconds."""
raw = str(text).strip().lower()
units = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
unit = units.get(raw[-1:]) if raw else None
try:
return int(float(raw[:-1] if unit else raw) * (unit or 1))
except ValueError:
return 0
def folder_usage(folder):
"""(bytes, files) under a folder, symlinks not followed."""
total, count = 0, 0
for path in Path(folder).rglob("*"):
try:
if path.is_file() and not path.is_symlink():
total += path.stat().st_size
count += 1
except OSError:
continue
return total, count
def purge_area(folder, quota=None, keep=None, by="mtime"):
"""Make room, oldest first, and never touch what is still inside the rules.
`by="atime"` is what a cache wants: a song played every day stays even if
it is the oldest thing there. `by="mtime"` is what a log wants. Getting
this backwards is how a cache throws away exactly what people use.
"""
folder = Path(folder)
if not folder.is_dir():
return 0, 0
files = []
for path in folder.rglob("*"):
try:
if not path.is_file() or path.is_symlink():
continue
info = path.stat()
age = info.st_atime if by == "atime" else info.st_mtime
files.append((age, info.st_size, path))
except OSError:
continue
files.sort()
freed, gone = 0, 0
moment = time.time()
remaining = sum(size for _, size, _ in files)
for age, size, path in files:
too_old = keep is not None and (moment - age) > keep
too_big = quota is not None and remaining > quota
if not (too_old or too_big):
continue
try:
path.unlink()
except OSError:
continue
freed += size
gone += 1
remaining -= size
return freed, gone
def control_socket_for(run_dir):
"""Where this server's control socket lives, short enough to bind.
Normally right next to the other runtime files. When that path is too long
for the kernel, in a temp directory instead, and `control.sock.path` in the
run folder says where it went so clients can still find it.
"""
run_dir = Path(run_dir)
natural = run_dir / "control.sock"
if len(str(natural)) <= SOCK_MAX:
return natural
digest = hashlib.sha1(str(run_dir.resolve()).encode()).hexdigest()[:16]
short = Path(tempfile.gettempdir()) / ("w4ve-%s.sock" % digest)
try:
run_dir.mkdir(parents=True, exist_ok=True)
(run_dir / "control.sock.path").write_text(str(short) + "\n")
except OSError:
pass
return short
def control_socket_of(root):
"""The client side of the same question."""
run_dir = Path(root) / "w4ve" / "run"
pointer = run_dir / "control.sock.path"
if pointer.exists():
try:
return Path(pointer.read_text().strip())
except OSError:
pass
return run_dir / "control.sock"
class Guardian:
"""One per server directory, and the lock file makes sure of it."""
def __init__(self, root, config=None):
self.root = Path(root).resolve()
self.config = config or {}
self.run_dir = self.root / "w4ve" / "run"
self.run_dir.mkdir(parents=True, exist_ok=True)
self.journal = self.run_dir / "journal.log"
self.state_path = self.run_dir / "state.json"
self.sock_path = control_socket_for(self.run_dir)
self.lock_path = self.run_dir / "lock"
self.lock_fd = None
self.watched = {}
self.timers = {}
self.storage = {}
self.listeners = []
self.listeners_lock = threading.Lock()
self.echo = False # print the console here, for `w4ve run`
self.stopping = threading.Event()
# The MCDR compatible plugin host, if this server has plugins and this
# w4ve carries the compatibility layer. None means "no plugins", which
# is a normal server, not a broken one.
self.plugins = None
# Native plugins (RFC-0003), each one a process of its own.
self.workers = None
# The local API (M8), if this w4ve carries it and the server wants it.
self.api = None
# ------------------------------------------------------------------ setup
def claim(self):
"""Take the instance lock, or say who has it.
Two guardians over one server is the fastest way to start a world
twice, and a world started twice is a world corrupted once.
"""
self.lock_fd = os.open(str(self.lock_path), os.O_RDWR | os.O_CREAT, 0o644)
try:
fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
holder = ""
try:
holder = Path(self.lock_path).read_text().strip()
except OSError:
pass
os.close(self.lock_fd)
self.lock_fd = None
return False, holder
os.ftruncate(self.lock_fd, 0)
os.write(self.lock_fd, ("%d\n" % os.getpid()).encode())
return True, ""
def release(self):
if self.lock_fd is not None:
fcntl.flock(self.lock_fd, fcntl.LOCK_UN)
os.close(self.lock_fd)
self.lock_fd = None
def log(self, text):
line = "%s %s\n" % (now(), text)
with open(str(self.journal), "a") as fh:
fh.write(line)
# ------------------------------------------------------------------ state
def snapshot(self):
"""Write down what we believe, so the next guardian can check it.
This file is a cache, never the truth. The truth is /proc, and every
read of this file verifies (pid, starttime) before believing a word.
"""
data = {
"schema": SCHEMA,
"written": now(),
"guardian_pid": os.getpid(),
"processes": {},
}
for name, proc in self.watched.items():
data["processes"][name] = {
"state": proc.state,
"ready": proc.ready,
"pid": proc.pid,
"starttime": proc.starttime,
"owned": proc.owned,
"started_at": proc.started_at,
"command": proc.command,
"cwd": str(proc.cwd),
"flavor": proc.handler.name if proc.handler else None,
}
tmp = self.state_path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, indent=2) + "\n")
tmp.replace(self.state_path)
def read_state(self):
try:
return json.loads(self.state_path.read_text())
except (OSError, ValueError):
return {}
# ------------------------------------------------------------- the server
def add_server(self, command, game_dir, flavor=None, stop_grace=STOP_GRACE,
restart="never", watches=()):
flavor = flavor or detect_flavor(game_dir)
proc = Watched("server", command, game_dir, self,
handler=handler_for(flavor), restart=restart,
stop_grace=stop_grace, watches=watches)
self.watched["server"] = proc
return proc
def add_service(self, name, command, cwd, restart="on-failure",
health=None, health_every=30, env=None, mcdr=False,
stop_grace=SERVICE_GRACE, after=()):
env = dict(env or {})
if mcdr:
# The service said it imports `mcdreforged`. ChatBridge's daemon
# does, and it is not a plugin: it is a separate process started
# from a command line. Rather than asking somebody to pip install
# all of MCDR for one class, W4VE puts its own on the path.
compat = mcdr_compat_path()
if compat is None:
self.log("%s asked for the mcdreforged compatibility package, "
"which is not next to this runtime" % name)
else:
existing = env.get("PYTHONPATH") or os.environ.get("PYTHONPATH")
env["PYTHONPATH"] = (str(compat) + os.pathsep + existing
if existing else str(compat))
proc = Watched(name, command, cwd, self, handler=None, restart=restart,
health=health, health_every=health_every, env=env,
stop_grace=stop_grace, after=after)
self.watched[name] = proc
return proc
def add_timer(self, name, command, every, cwd=".", env=None, why=""):
"""Something to run again every so often, watched instead of cron'd.
A cron entry is a promise nobody checks: the ShapeBoard scan was dead
for three days and the only symptom was a board that stopped changing.
A timer here runs in the guardian, its output goes to the journal, and
a run that fails says so out loud the first time and then keeps count.
"""
self.timers[name] = {
"name": name,
"command": list(command),
"every": max(5, int(every)),
"cwd": str(Path(self.root) / cwd),
"env": dict(env or {}),
"why": why,
"last": 0.0,
"runs": 0,
"failures": 0,
"streak": 0,
"last_error": "",
}
return self.timers[name]
def add_storage(self, name, folder, quota=None, keep=None, by="mtime",
why=""):
"""A folder a piece writes to, with a ceiling and a way to make room."""
self.storage[name] = {
"name": name,
"dir": str(Path(self.root) / folder),
"quota": quota,
"keep": keep,
"by": by,
"why": why,
"last_purge": 0.0,
}
Path(self.storage[name]["dir"]).mkdir(parents=True, exist_ok=True)
return self.storage[name]
def _housekeeping(self):
"""The loop that runs the timers and keeps the disk inside its lines.
One thread for both because they are the same kind of work: something
that has to happen on its own, that nobody is watching, and that must
never be able to hold up the guardian noticing that the server died.
"""
while not self.stopping.is_set():
moment = time.time()
for timer in list(self.timers.values()):
if moment - timer["last"] < timer["every"]:
continue
timer["last"] = moment
self._run_timer(timer)
for area in list(self.storage.values()):
if moment - area["last_purge"] < STORAGE_EVERY:
continue
area["last_purge"] = moment
self._purge_storage(area)
self.stopping.wait(1.0)
def _run_timer(self, timer):
environment = dict(os.environ)
environment.update(timer["env"])
try:
result = subprocess.run(
timer["command"], cwd=timer["cwd"], env=environment,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=TIMER_TIMEOUT)
except subprocess.TimeoutExpired:
timer["failures"] += 1
timer["streak"] += 1
timer["last_error"] = "took longer than %ds" % TIMER_TIMEOUT
self.log("timer %s %s" % (timer["name"], timer["last_error"]))
return
except OSError as exc:
timer["failures"] += 1
timer["streak"] += 1
timer["last_error"] = str(exc)
self.log("timer %s could not run: %s" % (timer["name"], exc))
return
timer["runs"] += 1
if result.returncode == 0:
if timer["streak"]:
self.log("timer %s is working again after %d failures"
% (timer["name"], timer["streak"]))
timer["streak"] = 0
return
timer["failures"] += 1
timer["streak"] += 1
tail = (result.stdout or b"").decode("utf-8", "replace").strip()
timer["last_error"] = tail.splitlines()[-1] if tail else "exit %s" % result.returncode
# Loud the first time and then quiet: a timer failing every minute
# would bury everything else in the journal, which is how people learn
# to ignore it.
if timer["streak"] == 1 or timer["streak"] % 60 == 0:
self.log("timer %s failed (%d in a row): %s"
% (timer["name"], timer["streak"], timer["last_error"]))
def _purge_storage(self, area):
quota = parse_size(area["quota"]) if area["quota"] else None
keep = parse_time(area["keep"]) if area["keep"] else None
freed, gone = purge_area(Path(area["dir"]), quota, keep, area["by"])
if gone:
self.log("storage %s: freed %d bytes in %d files"
% (area["name"], freed, gone))
def wait_for_dependencies(self, proc, timeout=DEPENDENCY_WAIT):
"""Hold a service back until what it needs is actually up.
Starting in the right order was never the problem: `start()` returns
as soon as the process exists, and a daemon needs a moment more to
bind its port. ChatBridge's !!online client came up first, failed to
connect three times, and reconnected on its own ten seconds later. It
healed itself, and it still filled the console with errors on every
boot.
What counts as up: ready, if the thing has a health check that can
answer; alive, if it has none. Waiting for readiness that can never
arrive would be worse than not waiting at all, so a service with no
health check is not waited on beyond being alive.
"""
for name in getattr(proc, "after", ()):
other = self.watched.get(name)
if other is None:
self.log("%s waits for %s, which is not declared here"
% (proc.name, name))
continue
deadline = time.time() + timeout
while time.time() < deadline and not self.stopping.is_set():
if not alive(other.pid, other.starttime):
time.sleep(0.2)
continue
if other.health is None or other.ready:
break
time.sleep(0.2)
else:
self.log("%s waited %ds for %s and started anyway"
% (proc.name, timeout, name))
continue
self.log("%s waited for %s" % (proc.name, name))
def recover(self):
"""Find processes from a previous life and take them back.