ser2netweb 3.0.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.
- ser2netweb/__init__.py +20 -0
- ser2netweb/__main__.py +6 -0
- ser2netweb/accounts.py +503 -0
- ser2netweb/cli.py +489 -0
- ser2netweb/clock.py +128 -0
- ser2netweb/config.py +4491 -0
- ser2netweb/console.py +181 -0
- ser2netweb/datalock.py +181 -0
- ser2netweb/engine/__init__.py +0 -0
- ser2netweb/engine/access.py +173 -0
- ser2netweb/engine/bindhint.py +115 -0
- ser2netweb/engine/bridge.py +1928 -0
- ser2netweb/engine/cluster.py +1140 -0
- ser2netweb/engine/modbus.py +530 -0
- ser2netweb/engine/modbus_gateway.py +1465 -0
- ser2netweb/engine/mqtt_pub.py +632 -0
- ser2netweb/engine/netguard.py +805 -0
- ser2netweb/engine/netinfo.py +199 -0
- ser2netweb/engine/portdiag.py +414 -0
- ser2netweb/engine/portlist.py +471 -0
- ser2netweb/engine/protocols/__init__.py +28 -0
- ser2netweb/engine/protocols/base.py +117 -0
- ser2netweb/engine/protocols/raw.py +12 -0
- ser2netweb/engine/protocols/rfc2217.py +586 -0
- ser2netweb/engine/protocols/telnet.py +172 -0
- ser2netweb/engine/serial_compat.py +598 -0
- ser2netweb/engine/serial_io.py +210 -0
- ser2netweb/engine/serial_link.py +414 -0
- ser2netweb/engine/sockopts.py +103 -0
- ser2netweb/engine/sparkplug.py +538 -0
- ser2netweb/engine/sparkplug_node.py +550 -0
- ser2netweb/engine/supervisor.py +533 -0
- ser2netweb/engine/tls.py +232 -0
- ser2netweb/envvars.py +57 -0
- ser2netweb/explain.py +117 -0
- ser2netweb/healthcheck.py +78 -0
- ser2netweb/i18n.py +110 -0
- ser2netweb/importer.py +550 -0
- ser2netweb/layout.py +118 -0
- ser2netweb/licences.py +86 -0
- ser2netweb/locale/tr.json +1569 -0
- ser2netweb/logsetup.py +253 -0
- ser2netweb/logtext.py +23 -0
- ser2netweb/notify.py +761 -0
- ser2netweb/optional.py +76 -0
- ser2netweb/reload.py +295 -0
- ser2netweb/runtime.py +762 -0
- ser2netweb/sdnotify.py +85 -0
- ser2netweb/ser2net_import.py +2001 -0
- ser2netweb/signins.py +98 -0
- ser2netweb/standalone.py +154 -0
- ser2netweb/state.py +1457 -0
- ser2netweb/storage.py +130 -0
- ser2netweb/textfold.py +51 -0
- ser2netweb/tlsreload.py +37 -0
- ser2netweb/watchdog.py +201 -0
- ser2netweb/web/__init__.py +0 -0
- ser2netweb/web/api.py +793 -0
- ser2netweb/web/auth.py +699 -0
- ser2netweb/web/certinfo.py +144 -0
- ser2netweb/web/httpguard.py +413 -0
- ser2netweb/web/ldap_auth.py +556 -0
- ser2netweb/web/oidc_auth.py +330 -0
- ser2netweb/web/proxy.py +398 -0
- ser2netweb/web/routes/__init__.py +78 -0
- ser2netweb/web/routes/cluster.py +669 -0
- ser2netweb/web/routes/common.py +359 -0
- ser2netweb/web/routes/console.py +451 -0
- ser2netweb/web/routes/forms.py +853 -0
- ser2netweb/web/routes/login.py +573 -0
- ser2netweb/web/routes/mappings.py +470 -0
- ser2netweb/web/routes/ops.py +374 -0
- ser2netweb/web/routes/settings.py +2135 -0
- ser2netweb/web/routes/system.py +308 -0
- ser2netweb/web/routes/totp.py +282 -0
- ser2netweb/web/selfsigned.py +129 -0
- ser2netweb/web/server.py +483 -0
- ser2netweb/web/static/app.css +327 -0
- ser2netweb/web/static/app.js +1102 -0
- ser2netweb/web/static/htmx.min.js +1 -0
- ser2netweb/web/static/points.js +218 -0
- ser2netweb/web/static/theme.js +7 -0
- ser2netweb/web/static/xterm-addon-fit.js +2 -0
- ser2netweb/web/static/xterm.css +218 -0
- ser2netweb/web/static/xterm.js +2 -0
- ser2netweb/web/templates/_cluster_body.html +109 -0
- ser2netweb/web/templates/_console.html +21 -0
- ser2netweb/web/templates/_footer.html +13 -0
- ser2netweb/web/templates/_login_notice.html +8 -0
- ser2netweb/web/templates/_macros.html +21 -0
- ser2netweb/web/templates/_mapping_form.html +477 -0
- ser2netweb/web/templates/_mapping_log.html +28 -0
- ser2netweb/web/templates/_mapping_traces.html +44 -0
- ser2netweb/web/templates/_mappings_body.html +149 -0
- ser2netweb/web/templates/_modbus_read.html +18 -0
- ser2netweb/web/templates/_ports_table.html +37 -0
- ser2netweb/web/templates/_serial_port_select.html +48 -0
- ser2netweb/web/templates/_shell.html +41 -0
- ser2netweb/web/templates/audit.html +55 -0
- ser2netweb/web/templates/base.html +77 -0
- ser2netweb/web/templates/dashboard.html +72 -0
- ser2netweb/web/templates/licenses.html +18 -0
- ser2netweb/web/templates/login.html +32 -0
- ser2netweb/web/templates/login_totp.html +23 -0
- ser2netweb/web/templates/settings.html +830 -0
- ser2netweb/web/templates/setup.html +43 -0
- ser2netweb/web/templates/totp.html +41 -0
- ser2netweb/web/totp.py +94 -0
- ser2netweb/winservice.py +1793 -0
- ser2netweb-3.0.0.dist-info/METADATA +120 -0
- ser2netweb-3.0.0.dist-info/RECORD +121 -0
- ser2netweb-3.0.0.dist-info/WHEEL +5 -0
- ser2netweb-3.0.0.dist-info/entry_points.txt +2 -0
- ser2netweb-3.0.0.dist-info/licenses/COMMERCIAL_LICENSE.md +94 -0
- ser2netweb-3.0.0.dist-info/licenses/LICENSE +662 -0
- ser2netweb-3.0.0.dist-info/licenses/NOTICE +20 -0
- ser2netweb-3.0.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +105 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/htmx.org.txt +13 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm-addon-fit.txt +19 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm.txt +21 -0
- ser2netweb-3.0.0.dist-info/top_level.txt +1 -0
ser2netweb/storage.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""What ser2netweb keeps on disk: the free space it needs, and the clock its retention goes by.
|
|
2
|
+
|
|
3
|
+
Traces can fill a disk (up to trace_max_mb x (1 + trace_keep) per mapping), and a full
|
|
4
|
+
disk keeps config.json, the logs and the audit log from being written. Below ``floor()``
|
|
5
|
+
free space on the data folder's disk (max(256 MiB, 5% of the disk), at most 1 GiB, and at
|
|
6
|
+
most a quarter of a small disk) a trace pauses (``check_space``, in its writer thread) and
|
|
7
|
+
goes on by itself once there is 10% more than that again; /metrics reports the free space
|
|
8
|
+
and what traces and logs take (``usage()``), and the optional disk_low notification says
|
|
9
|
+
when it gets low (notify.Notifier).
|
|
10
|
+
|
|
11
|
+
Retention deletes by age (the mapping logs' 15 days, Settings -> Retention). ``now()`` is
|
|
12
|
+
the time it compares with: the system clock, but never ahead of the system clock at
|
|
13
|
+
start-up plus the time this process has run (measured with a clock no step changes). A
|
|
14
|
+
clock stepped ahead while ser2netweb runs (a typo, a bad time source) so deletes nothing
|
|
15
|
+
early; one stepped back is followed at once (that only keeps more). After a restart the
|
|
16
|
+
system clock counts again: by then the step is either corrected or a real one.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import errno
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import stat
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
|
|
27
|
+
FREE_FLOOR_MIN = 256 * 1024 * 1024
|
|
28
|
+
FREE_FLOOR_SHARE = 0.05
|
|
29
|
+
FREE_FLOOR_MAX = 1024 * 1024 * 1024
|
|
30
|
+
FREE_RESUME = 1.1 # a trace paused for space goes on above floor() x this
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LowSpace(OSError):
|
|
34
|
+
"""Too little free space on the disk to write a trace (check_space)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def space(path: str) -> tuple[int, int] | None:
|
|
38
|
+
"""(free, total) bytes of the file system `path` is on (free: what this process may
|
|
39
|
+
use, without the part reserved for root), or None when that can't be read."""
|
|
40
|
+
try:
|
|
41
|
+
usage = shutil.disk_usage(path)
|
|
42
|
+
except (OSError, ValueError):
|
|
43
|
+
return None
|
|
44
|
+
return usage.free, usage.total
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def floor(total: int) -> int:
|
|
48
|
+
"""The free space below which traces pause on a disk of `total` bytes."""
|
|
49
|
+
return min(max(FREE_FLOOR_MIN, min(int(total * FREE_FLOOR_SHARE), FREE_FLOOR_MAX)),
|
|
50
|
+
total // 4)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def mb(n: float) -> str:
|
|
54
|
+
return f"{n / 1048576:.0f} MB"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def size_text(n: float) -> str:
|
|
58
|
+
"""A size as the UI shows it: '1.2 GB', '340 MB', '12 KB', '0 KB'."""
|
|
59
|
+
if n >= 1024 ** 3:
|
|
60
|
+
return f"{n / 1024 ** 3:.1f} GB"
|
|
61
|
+
return mb(n) if n >= 1048576 else f"{n / 1024:.0f} KB"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_space(path: str, resuming: bool = False) -> None:
|
|
65
|
+
"""Raise LowSpace when the disk `path` is on has less free space than floor() (with
|
|
66
|
+
`resuming`: than FREE_RESUME times that, so that a trace paused for it doesn't flap);
|
|
67
|
+
nothing when there is enough, or when that can't be told."""
|
|
68
|
+
found = space(path)
|
|
69
|
+
if found is None:
|
|
70
|
+
return
|
|
71
|
+
free, total = found
|
|
72
|
+
need = floor(total)
|
|
73
|
+
if free < (need * FREE_RESUME if resuming else need):
|
|
74
|
+
raise LowSpace(errno.ENOSPC, f"the disk is almost full ({mb(free)} free; traces "
|
|
75
|
+
f"pause below {mb(need)})")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def folder_bytes(folder: str, prefixes=None) -> int:
|
|
79
|
+
"""The size of the regular files in `folder` (not in its subfolders; with `prefixes`,
|
|
80
|
+
only those whose name begins with one of them). Blocking."""
|
|
81
|
+
total = 0
|
|
82
|
+
try:
|
|
83
|
+
with os.scandir(folder) as entries:
|
|
84
|
+
for entry in entries:
|
|
85
|
+
if prefixes is not None and not entry.name.startswith(tuple(prefixes)):
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
st = entry.stat(follow_symlinks=False)
|
|
89
|
+
except OSError:
|
|
90
|
+
continue
|
|
91
|
+
if stat.S_ISREG(st.st_mode):
|
|
92
|
+
total += st.st_size
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
return total
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def usage(data_dir: str) -> dict:
|
|
99
|
+
"""The data folder's disk and what ser2netweb keeps there: free and total bytes of the
|
|
100
|
+
disk (None when they can't be read), the floor traces pause below, and the bytes of the
|
|
101
|
+
traces (traces/) and of the logs (logs/, all.log*, audit.log*). Blocking: a few
|
|
102
|
+
directory listings."""
|
|
103
|
+
found = space(data_dir)
|
|
104
|
+
free, total = found if found is not None else (None, None)
|
|
105
|
+
return {"free": free, "total": total, "floor": floor(total) if total else None,
|
|
106
|
+
"traces": folder_bytes(os.path.join(data_dir, "traces")),
|
|
107
|
+
"logs": folder_bytes(os.path.join(data_dir, "logs"))
|
|
108
|
+
+ folder_bytes(data_dir, ("all.log", "audit.log"))}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _elapsed() -> float:
|
|
112
|
+
"""Seconds since an arbitrary start that no clock step changes: CLOCK_BOOTTIME on
|
|
113
|
+
Linux (it also counts a suspend), else time.monotonic()."""
|
|
114
|
+
if sys.platform != "win32": # (no clock_gettime there)
|
|
115
|
+
boottime = getattr(time, "CLOCK_BOOTTIME", None)
|
|
116
|
+
if boottime is not None:
|
|
117
|
+
try:
|
|
118
|
+
return time.clock_gettime(boottime)
|
|
119
|
+
except OSError:
|
|
120
|
+
pass
|
|
121
|
+
return time.monotonic()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_STARTED = (time.time(), _elapsed())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def now() -> float:
|
|
128
|
+
"""The time retention compares ages with (see the module docstring)."""
|
|
129
|
+
wall, elapsed = _STARTED
|
|
130
|
+
return min(time.time(), wall + (_elapsed() - elapsed))
|
ser2netweb/textfold.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""How text compares where letter case or accents don't count, and how names sort.
|
|
2
|
+
|
|
3
|
+
* fold(): one name, whatever its letter case (LDAP / OIDC group and user names, account
|
|
4
|
+
names) — Turkish İ, I, ı and i are one letter, accents count (Çorlu is not Corlu);
|
|
5
|
+
* search_key(): what a search compares (the dashboard filter, the audit log search) —
|
|
6
|
+
also without accents and compatibility forms, so 'istanbul' finds 'İstanbul Depo' and
|
|
7
|
+
'corlu' finds 'Çorlu'. app.js folds what is typed into the dashboard filter the same
|
|
8
|
+
way (searchKey); the rows' text is folded here, on the server;
|
|
9
|
+
* natural_key(): a sort key where digit runs count as numbers (COM2 before COM10,
|
|
10
|
+
'Hat 2' before 'Hat 10').
|
|
11
|
+
|
|
12
|
+
str.lower() is no use for either: it turns 'İ' into 'i' + U+0307 and leaves 'ı' as it
|
|
13
|
+
is, so KALİTE and Kalite, BAKIM and Bakım, or 'istanbul' and 'İstanbul' would differ.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
import unicodedata
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fold(text) -> str:
|
|
22
|
+
"""How names compare where letter case doesn't count (LDAP and OIDC group names, LDAP
|
|
23
|
+
user names, an LDAP/OIDC sign-in name or a new account's name against the existing
|
|
24
|
+
ones): NFC, every run of white space (no-break spaces too) as one space, and letter
|
|
25
|
+
case folded so that Turkish İ, I, ı and i are one letter — str.lower() turns 'İ'
|
|
26
|
+
into 'i' + U+0307 and leaves 'ı' as it is, so KALİTE and Kalite, or BAKIM and Bakım,
|
|
27
|
+
would differ."""
|
|
28
|
+
s = " ".join(unicodedata.normalize("NFC", str(text)).split())
|
|
29
|
+
return s.replace("\u0130", "i").replace("\u0131", "i").casefold().replace("i\u0307", "i")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def search_key(text) -> str:
|
|
33
|
+
"""`text` as a search compares it: compatibility forms spelled out and accents left
|
|
34
|
+
off (NFKD without its combining marks: Ç is C, Ş is S, fi is fi, a no-break space a
|
|
35
|
+
space), then fold(). A search for search_key(typed) in search_key(text) finds
|
|
36
|
+
'İstanbul Depo' by 'istanbul', 'IŞIK KONTROL' by 'ışık' or 'isik', 'KAZAN DAİRESİ'
|
|
37
|
+
by 'dairesi'. app.js does the same to what is typed (searchKey)."""
|
|
38
|
+
s = unicodedata.normalize("NFKD", str(text))
|
|
39
|
+
return fold("".join(c for c in s if not unicodedata.category(c).startswith("M")))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_DIGITS = re.compile(r"([0-9]+)")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def natural_key(text) -> tuple:
|
|
46
|
+
"""A sort key for names with numbers in them: digit runs compare as numbers (COM1,
|
|
47
|
+
COM2, COM10; /dev/ttyS2 before /dev/ttyS10), the rest as search_key() spells it
|
|
48
|
+
(letter case and accents don't count), and the text itself last, so equal keys
|
|
49
|
+
still sort one way."""
|
|
50
|
+
parts = _DIGITS.split(search_key(text)) # text, digits, text, ... (text first)
|
|
51
|
+
return (tuple(int(p) if i % 2 else p for i, p in enumerate(parts)), str(text))
|
ser2netweb/tlsreload.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""TLS certificates that change while ser2netweb runs: new connections get a context built from
|
|
2
|
+
the settings and files as they are now, connections already open keep theirs, and no
|
|
3
|
+
listener is restarted (the admin UI's AdminGuard and a mapping's netguard.Listener read
|
|
4
|
+
their context at each accept).
|
|
5
|
+
|
|
6
|
+
``prepare(build)`` is the one place a context is made for that: ``build()`` makes it from
|
|
7
|
+
the settings and their files (it raises when they can't be used) and runs in a worker
|
|
8
|
+
thread; the caller hands the result to the listener only when it is usable.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import ssl
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Callable, Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Prepared:
|
|
20
|
+
"""What prepare() made: a context to use (None: no TLS), or why there is none."""
|
|
21
|
+
context: Optional[ssl.SSLContext] = None
|
|
22
|
+
error: str = "" # the files can't be used (the change is refused, the context in use stays)
|
|
23
|
+
later: str = "" # usable only from the next start (no listener in this process)
|
|
24
|
+
exc: Optional[BaseException] = None # what made `error` (engine.tls.TlsFileError: the
|
|
25
|
+
# Settings page explains it in its language)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def usable(self) -> bool:
|
|
29
|
+
return not (self.error or self.later)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def prepare(build: Callable[[], Optional[ssl.SSLContext]]) -> Prepared:
|
|
33
|
+
"""A new TLS context from ``build()`` (None for no TLS), made in a worker thread."""
|
|
34
|
+
try:
|
|
35
|
+
return Prepared(await asyncio.to_thread(build))
|
|
36
|
+
except (OSError, ValueError) as e: # (engine.tls.TlsFileError is a ValueError) # ssl.SSLError is an OSError
|
|
37
|
+
return Prepared(error=str(e) or type(e).__name__, exc=e)
|
ser2netweb/watchdog.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Event loop watchdog: a stuck event loop ends the process, so it is started again.
|
|
2
|
+
|
|
3
|
+
Every bridge, the Modbus gateway, MQTT, the cluster and the web UI run on one asyncio
|
|
4
|
+
event loop. When a call blocks it (a write to a device, file or console that never
|
|
5
|
+
returns), all of them stop, but the process lives on: systemd, Docker and the Windows
|
|
6
|
+
Service Control Manager only act when it exits, a failing /healthz only marks a container
|
|
7
|
+
"unhealthy", and paho-mqtt's own thread keeps the MQTT sessions alive, so the 'online'
|
|
8
|
+
status and the Sparkplug death certificate are never sent and SCADA sees stale values as
|
|
9
|
+
live ones.
|
|
10
|
+
|
|
11
|
+
A heartbeat task on the loop notes the time every BEAT_S seconds. A daemon thread checks
|
|
12
|
+
it: once the loop has missed its heartbeat for ``limit`` seconds (SER2NETWEB_LOOP_WATCHDOG,
|
|
13
|
+
default DEFAULT_LIMIT_S, 0 = off), it writes every thread's stack (faulthandler) to
|
|
14
|
+
<data>/hang.log, which names the call that blocks, and ends the process with EXIT_CODE;
|
|
15
|
+
the service manager (restart policy) starts it again, and the next start reports it.
|
|
16
|
+
|
|
17
|
+
Why 90 s: a loop that is busy, however much, still runs its heartbeat within about a
|
|
18
|
+
second, because its work is cut into short callbacks; only a loop that never gets back to
|
|
19
|
+
them misses it. The longest legitimate holds known are seconds (a log write under heavy
|
|
20
|
+
disk pressure, swapping on a small board), so 90 s is an order of magnitude more, and
|
|
21
|
+
above what a Docker health check (3 tries 30 s apart) or a Kubernetes liveness probe
|
|
22
|
+
waits. /healthz itself stays a plain liveness answer ("ok" while the loop runs), so a
|
|
23
|
+
slow but working instance is never reported unhealthy for being busy.
|
|
24
|
+
|
|
25
|
+
The thread measures with time.monotonic() and time.sleep() (not Event.wait). When its own
|
|
26
|
+
sleep took much longer than asked, the whole process was frozen (the machine slept, the
|
|
27
|
+
container was paused, a VM was suspended) and the loop could not have run either: it
|
|
28
|
+
starts measuring again instead of ending a process that is fine. A loop that keeps
|
|
29
|
+
running but spins (a callback that reschedules itself at once) still beats and is not
|
|
30
|
+
caught; neither is a stall in native code that holds Python's global interpreter lock
|
|
31
|
+
(it looks like a frozen process). Standard library only.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import asyncio
|
|
36
|
+
import contextlib
|
|
37
|
+
import faulthandler
|
|
38
|
+
import os
|
|
39
|
+
import sys
|
|
40
|
+
import threading
|
|
41
|
+
import time
|
|
42
|
+
from typing import Callable, Mapping, Optional
|
|
43
|
+
|
|
44
|
+
from . import clock, envvars, sdnotify
|
|
45
|
+
|
|
46
|
+
ENV = "SER2NETWEB_LOOP_WATCHDOG" # (or SER2NET_LOOP_WATCHDOG, its 2.9.x name)
|
|
47
|
+
DEFAULT_LIMIT_S = 90.0
|
|
48
|
+
BEAT_S = 1.0 # the heartbeat's interval on the loop
|
|
49
|
+
CHECK_S = 1.0 # how often the thread looks at it
|
|
50
|
+
FROZEN_S = 5.0 # the thread's sleep took this much longer: the process was frozen
|
|
51
|
+
SLOW_BEAT_S = 2.0 # a heartbeat this late is logged (at most once a minute)
|
|
52
|
+
EXIT_CODE = 70 # EX_SOFTWARE
|
|
53
|
+
EXIT_AFTER_S = 10.0 # the exit comes this long after the limit at the latest
|
|
54
|
+
HANG_LOG = "hang.log"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def limit_from_env(env: Optional[Mapping[str, str]] = None) -> tuple[float, str]:
|
|
58
|
+
"""(seconds, a note for the start-up log or ""): SER2NETWEB_LOOP_WATCHDOG, 0 = off."""
|
|
59
|
+
raw = envvars.text(ENV, env)
|
|
60
|
+
if not raw:
|
|
61
|
+
return DEFAULT_LIMIT_S, ""
|
|
62
|
+
try:
|
|
63
|
+
value = float(raw)
|
|
64
|
+
except ValueError:
|
|
65
|
+
value = -1.0
|
|
66
|
+
if not 0 <= value <= 86400: # (NaN too)
|
|
67
|
+
return DEFAULT_LIMIT_S, (f"WARNING: {envvars.source(ENV, env)}={raw!r} is not a "
|
|
68
|
+
f"number of seconds "
|
|
69
|
+
f"(0 = off, at most 86400) — the event loop watchdog uses "
|
|
70
|
+
f"{DEFAULT_LIMIT_S:g} s")
|
|
71
|
+
return value, ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def previous_hang(data_dir: str) -> str:
|
|
75
|
+
"""When the previous run was ended by the watchdog (hang.log is there): its hang.log is
|
|
76
|
+
kept as hang.log.1 and the start-up log line about it is returned, else ""."""
|
|
77
|
+
path = os.path.join(data_dir, HANG_LOG)
|
|
78
|
+
try:
|
|
79
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
80
|
+
first = fh.readline().strip()
|
|
81
|
+
os.replace(path, path + ".1")
|
|
82
|
+
except OSError:
|
|
83
|
+
return ""
|
|
84
|
+
return (f"WARNING: the previous run was ended by the event loop watchdog ({first}) — "
|
|
85
|
+
f"the threads' stacks are in {path}.1")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class LoopWatchdog:
|
|
89
|
+
"""The heartbeat (a task on the loop) and the thread that watches it. ``lag`` is how
|
|
90
|
+
late the last heartbeat was (/metrics), ``slow_beats`` how many were SLOW_BEAT_S late."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, data_dir: str, log: Callable[[str], None], limit_s: float,
|
|
93
|
+
exit_process: Callable[[int], None] = os._exit) -> None:
|
|
94
|
+
self.data_dir = data_dir
|
|
95
|
+
self.log = log
|
|
96
|
+
self.limit_s = limit_s
|
|
97
|
+
self.lag = 0.0
|
|
98
|
+
self.slow_beats = 0
|
|
99
|
+
self._exit = exit_process
|
|
100
|
+
self.beat = time.monotonic()
|
|
101
|
+
self._stop = threading.Event()
|
|
102
|
+
self._thread: Optional[threading.Thread] = None
|
|
103
|
+
self._task: Optional[asyncio.Task] = None
|
|
104
|
+
self._said = 0.0 # time.monotonic() of the last "slow heartbeat" line
|
|
105
|
+
self._ping_s = sdnotify.watchdog_interval()
|
|
106
|
+
self._pinged = 0.0
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def hang_log(self) -> str:
|
|
110
|
+
return os.path.join(self.data_dir, HANG_LOG)
|
|
111
|
+
|
|
112
|
+
def start(self) -> None:
|
|
113
|
+
"""Begin the heartbeat (on the running loop) and, with a limit, the watching thread."""
|
|
114
|
+
self.beat = time.monotonic()
|
|
115
|
+
self._task = asyncio.get_running_loop().create_task(self._heartbeat(),
|
|
116
|
+
name="loop-heartbeat")
|
|
117
|
+
if self.limit_s > 0:
|
|
118
|
+
self._thread = threading.Thread(target=self._watch, name="loop-watchdog",
|
|
119
|
+
daemon=True)
|
|
120
|
+
self._thread.start()
|
|
121
|
+
|
|
122
|
+
async def stop(self) -> None:
|
|
123
|
+
self._stop.set()
|
|
124
|
+
if self._task is not None:
|
|
125
|
+
self._task.cancel()
|
|
126
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
127
|
+
await self._task
|
|
128
|
+
self._task = None
|
|
129
|
+
|
|
130
|
+
async def _heartbeat(self) -> None:
|
|
131
|
+
try:
|
|
132
|
+
await self._beats()
|
|
133
|
+
finally: # cancelled: the loop ends (also on an error before stop()), so does the thread
|
|
134
|
+
self._stop.set()
|
|
135
|
+
|
|
136
|
+
async def _beats(self) -> None:
|
|
137
|
+
while True:
|
|
138
|
+
asked = time.monotonic()
|
|
139
|
+
await asyncio.sleep(BEAT_S)
|
|
140
|
+
now = time.monotonic()
|
|
141
|
+
self.beat = now
|
|
142
|
+
self.lag = max(0.0, now - asked - BEAT_S)
|
|
143
|
+
if self.lag >= SLOW_BEAT_S:
|
|
144
|
+
self.slow_beats += 1
|
|
145
|
+
if now - self._said >= 60:
|
|
146
|
+
self._said = now
|
|
147
|
+
self.log(f"event loop: held up for {self.lag:.1f} s (the mappings and "
|
|
148
|
+
"the web UI waited meanwhile)")
|
|
149
|
+
if self._ping_s is not None and now - self._pinged >= self._ping_s:
|
|
150
|
+
self._pinged = now
|
|
151
|
+
sdnotify.watchdog()
|
|
152
|
+
|
|
153
|
+
def _watch(self) -> None:
|
|
154
|
+
last = time.monotonic()
|
|
155
|
+
while not self._stop.is_set():
|
|
156
|
+
time.sleep(CHECK_S)
|
|
157
|
+
now = time.monotonic()
|
|
158
|
+
if now - last > CHECK_S + FROZEN_S:
|
|
159
|
+
self.beat = max(self.beat, now) # the process was frozen: measure anew
|
|
160
|
+
last = now
|
|
161
|
+
if now - self.beat > self.limit_s and not self._stop.is_set():
|
|
162
|
+
self._fire(now - self.beat)
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
def _fire(self, stuck: float) -> None:
|
|
166
|
+
"""Write every thread's stack to hang.log, then end the process (after
|
|
167
|
+
EXIT_AFTER_S at the latest, should writing block too)."""
|
|
168
|
+
backstop = threading.Timer(EXIT_AFTER_S, self._exit, (EXIT_CODE,))
|
|
169
|
+
backstop.daemon = True
|
|
170
|
+
backstop.start()
|
|
171
|
+
line = (f"{clock.stamp()} the event loop did not run for "
|
|
172
|
+
f"{stuck:.0f} s (limit {self.limit_s:g} s, {ENV})")
|
|
173
|
+
with contextlib.suppress(Exception):
|
|
174
|
+
with open(self.hang_log, "w", encoding="utf-8") as fh:
|
|
175
|
+
fh.write(line + "\n\n")
|
|
176
|
+
fh.flush()
|
|
177
|
+
faulthandler.dump_traceback(file=fh, all_threads=True)
|
|
178
|
+
with contextlib.suppress(Exception):
|
|
179
|
+
# straight to the file and to stderr: the log's own handlers may be what blocks
|
|
180
|
+
with open(os.path.join(self.data_dir, "all.log"), "a", encoding="utf-8") as fh:
|
|
181
|
+
fh.write(f"{line} — ser2netweb exits ({EXIT_CODE}) to be started again; the "
|
|
182
|
+
f"threads' stacks are in {self.hang_log}\n")
|
|
183
|
+
with contextlib.suppress(Exception):
|
|
184
|
+
print(f"ser2netweb: {line} — exiting; see {self.hang_log}", file=sys.stderr, flush=True)
|
|
185
|
+
self._exit(EXIT_CODE)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def start(data_dir: str, log: Callable[[str], None],
|
|
189
|
+
env: Optional[Mapping[str, str]] = None) -> LoopWatchdog:
|
|
190
|
+
"""The watchdog for the running loop, started; its start-up line is logged."""
|
|
191
|
+
limit, note = limit_from_env(env)
|
|
192
|
+
if note:
|
|
193
|
+
log(note)
|
|
194
|
+
dog = LoopWatchdog(data_dir, log, limit)
|
|
195
|
+
dog.start()
|
|
196
|
+
if limit > 0:
|
|
197
|
+
log(f"event loop watchdog: on — ser2netweb exits (to be started again) when its event "
|
|
198
|
+
f"loop does not run for {limit:g} s ({ENV}=0: off)")
|
|
199
|
+
else:
|
|
200
|
+
log(f"event loop watchdog: off ({ENV}=0)")
|
|
201
|
+
return dog
|
|
File without changes
|