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.
Files changed (121) hide show
  1. ser2netweb/__init__.py +20 -0
  2. ser2netweb/__main__.py +6 -0
  3. ser2netweb/accounts.py +503 -0
  4. ser2netweb/cli.py +489 -0
  5. ser2netweb/clock.py +128 -0
  6. ser2netweb/config.py +4491 -0
  7. ser2netweb/console.py +181 -0
  8. ser2netweb/datalock.py +181 -0
  9. ser2netweb/engine/__init__.py +0 -0
  10. ser2netweb/engine/access.py +173 -0
  11. ser2netweb/engine/bindhint.py +115 -0
  12. ser2netweb/engine/bridge.py +1928 -0
  13. ser2netweb/engine/cluster.py +1140 -0
  14. ser2netweb/engine/modbus.py +530 -0
  15. ser2netweb/engine/modbus_gateway.py +1465 -0
  16. ser2netweb/engine/mqtt_pub.py +632 -0
  17. ser2netweb/engine/netguard.py +805 -0
  18. ser2netweb/engine/netinfo.py +199 -0
  19. ser2netweb/engine/portdiag.py +414 -0
  20. ser2netweb/engine/portlist.py +471 -0
  21. ser2netweb/engine/protocols/__init__.py +28 -0
  22. ser2netweb/engine/protocols/base.py +117 -0
  23. ser2netweb/engine/protocols/raw.py +12 -0
  24. ser2netweb/engine/protocols/rfc2217.py +586 -0
  25. ser2netweb/engine/protocols/telnet.py +172 -0
  26. ser2netweb/engine/serial_compat.py +598 -0
  27. ser2netweb/engine/serial_io.py +210 -0
  28. ser2netweb/engine/serial_link.py +414 -0
  29. ser2netweb/engine/sockopts.py +103 -0
  30. ser2netweb/engine/sparkplug.py +538 -0
  31. ser2netweb/engine/sparkplug_node.py +550 -0
  32. ser2netweb/engine/supervisor.py +533 -0
  33. ser2netweb/engine/tls.py +232 -0
  34. ser2netweb/envvars.py +57 -0
  35. ser2netweb/explain.py +117 -0
  36. ser2netweb/healthcheck.py +78 -0
  37. ser2netweb/i18n.py +110 -0
  38. ser2netweb/importer.py +550 -0
  39. ser2netweb/layout.py +118 -0
  40. ser2netweb/licences.py +86 -0
  41. ser2netweb/locale/tr.json +1569 -0
  42. ser2netweb/logsetup.py +253 -0
  43. ser2netweb/logtext.py +23 -0
  44. ser2netweb/notify.py +761 -0
  45. ser2netweb/optional.py +76 -0
  46. ser2netweb/reload.py +295 -0
  47. ser2netweb/runtime.py +762 -0
  48. ser2netweb/sdnotify.py +85 -0
  49. ser2netweb/ser2net_import.py +2001 -0
  50. ser2netweb/signins.py +98 -0
  51. ser2netweb/standalone.py +154 -0
  52. ser2netweb/state.py +1457 -0
  53. ser2netweb/storage.py +130 -0
  54. ser2netweb/textfold.py +51 -0
  55. ser2netweb/tlsreload.py +37 -0
  56. ser2netweb/watchdog.py +201 -0
  57. ser2netweb/web/__init__.py +0 -0
  58. ser2netweb/web/api.py +793 -0
  59. ser2netweb/web/auth.py +699 -0
  60. ser2netweb/web/certinfo.py +144 -0
  61. ser2netweb/web/httpguard.py +413 -0
  62. ser2netweb/web/ldap_auth.py +556 -0
  63. ser2netweb/web/oidc_auth.py +330 -0
  64. ser2netweb/web/proxy.py +398 -0
  65. ser2netweb/web/routes/__init__.py +78 -0
  66. ser2netweb/web/routes/cluster.py +669 -0
  67. ser2netweb/web/routes/common.py +359 -0
  68. ser2netweb/web/routes/console.py +451 -0
  69. ser2netweb/web/routes/forms.py +853 -0
  70. ser2netweb/web/routes/login.py +573 -0
  71. ser2netweb/web/routes/mappings.py +470 -0
  72. ser2netweb/web/routes/ops.py +374 -0
  73. ser2netweb/web/routes/settings.py +2135 -0
  74. ser2netweb/web/routes/system.py +308 -0
  75. ser2netweb/web/routes/totp.py +282 -0
  76. ser2netweb/web/selfsigned.py +129 -0
  77. ser2netweb/web/server.py +483 -0
  78. ser2netweb/web/static/app.css +327 -0
  79. ser2netweb/web/static/app.js +1102 -0
  80. ser2netweb/web/static/htmx.min.js +1 -0
  81. ser2netweb/web/static/points.js +218 -0
  82. ser2netweb/web/static/theme.js +7 -0
  83. ser2netweb/web/static/xterm-addon-fit.js +2 -0
  84. ser2netweb/web/static/xterm.css +218 -0
  85. ser2netweb/web/static/xterm.js +2 -0
  86. ser2netweb/web/templates/_cluster_body.html +109 -0
  87. ser2netweb/web/templates/_console.html +21 -0
  88. ser2netweb/web/templates/_footer.html +13 -0
  89. ser2netweb/web/templates/_login_notice.html +8 -0
  90. ser2netweb/web/templates/_macros.html +21 -0
  91. ser2netweb/web/templates/_mapping_form.html +477 -0
  92. ser2netweb/web/templates/_mapping_log.html +28 -0
  93. ser2netweb/web/templates/_mapping_traces.html +44 -0
  94. ser2netweb/web/templates/_mappings_body.html +149 -0
  95. ser2netweb/web/templates/_modbus_read.html +18 -0
  96. ser2netweb/web/templates/_ports_table.html +37 -0
  97. ser2netweb/web/templates/_serial_port_select.html +48 -0
  98. ser2netweb/web/templates/_shell.html +41 -0
  99. ser2netweb/web/templates/audit.html +55 -0
  100. ser2netweb/web/templates/base.html +77 -0
  101. ser2netweb/web/templates/dashboard.html +72 -0
  102. ser2netweb/web/templates/licenses.html +18 -0
  103. ser2netweb/web/templates/login.html +32 -0
  104. ser2netweb/web/templates/login_totp.html +23 -0
  105. ser2netweb/web/templates/settings.html +830 -0
  106. ser2netweb/web/templates/setup.html +43 -0
  107. ser2netweb/web/templates/totp.html +41 -0
  108. ser2netweb/web/totp.py +94 -0
  109. ser2netweb/winservice.py +1793 -0
  110. ser2netweb-3.0.0.dist-info/METADATA +120 -0
  111. ser2netweb-3.0.0.dist-info/RECORD +121 -0
  112. ser2netweb-3.0.0.dist-info/WHEEL +5 -0
  113. ser2netweb-3.0.0.dist-info/entry_points.txt +2 -0
  114. ser2netweb-3.0.0.dist-info/licenses/COMMERCIAL_LICENSE.md +94 -0
  115. ser2netweb-3.0.0.dist-info/licenses/LICENSE +662 -0
  116. ser2netweb-3.0.0.dist-info/licenses/NOTICE +20 -0
  117. ser2netweb-3.0.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +105 -0
  118. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/htmx.org.txt +13 -0
  119. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm-addon-fit.txt +19 -0
  120. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm.txt +21 -0
  121. 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))
@@ -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