blockpath 0.1.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.
- blockpath/__init__.py +22 -0
- blockpath/analysis.py +329 -0
- blockpath/callgraph.py +298 -0
- blockpath/check.py +33 -0
- blockpath/cli.py +144 -0
- blockpath/collect.py +206 -0
- blockpath/config.py +75 -0
- blockpath/model.py +112 -0
- blockpath/oracle.py +143 -0
- blockpath/oracle.yaml +222 -0
- blockpath/py.typed +0 -0
- blockpath/report.py +140 -0
- blockpath/resolve.py +192 -0
- blockpath/runtime/__init__.py +22 -0
- blockpath/runtime/monitor.py +157 -0
- blockpath/runtime/predicate.py +39 -0
- blockpath/runtime/verify.py +177 -0
- blockpath/runtime/watchdog.py +117 -0
- blockpath/symbols.py +284 -0
- blockpath-0.1.0.dist-info/METADATA +180 -0
- blockpath-0.1.0.dist-info/RECORD +24 -0
- blockpath-0.1.0.dist-info/WHEEL +4 -0
- blockpath-0.1.0.dist-info/entry_points.txt +2 -0
- blockpath-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Which of the project's own functions actually executed while a loop was running.
|
|
2
|
+
|
|
3
|
+
Built on ``sys.monitoring`` (PEP 669, hence Python 3.12+, ADR 0001). The shape of the
|
|
4
|
+
instrumentation matters more than it looks:
|
|
5
|
+
|
|
6
|
+
* A ``PY_START`` callback is registered globally, but the *first* time it sees a code object
|
|
7
|
+
from outside the project it returns ``sys.monitoring.DISABLE``, which permanently stops events
|
|
8
|
+
for that code object. Third-party and stdlib code therefore costs one event each, once, and
|
|
9
|
+
nothing thereafter. Measured on a call-heavy workload: 1.09x baseline, against 1.29x for the
|
|
10
|
+
same callback without ``DISABLE``. That is the difference between a verifier you can leave on
|
|
11
|
+
during a test suite and one nobody runs twice.
|
|
12
|
+
* The tool id is 3 by default, not the reserved 0/1/2/5 (debugger, coverage, profiler,
|
|
13
|
+
optimizer). Checked empirically on 3.13 with ``COVERAGE_CORE=sysmon``: coverage.py holds id 1
|
|
14
|
+
and blockpath holds id 3 in the same process, both receiving their events. ``sys.monitoring``
|
|
15
|
+
multiplexes by tool id, so running under coverage is not a conflict.
|
|
16
|
+
|
|
17
|
+
Recording is gated on :func:`blockpath.runtime.predicate.on_event_loop`, so a project function
|
|
18
|
+
that runs in a worker thread (``to_thread``) is correctly *not* recorded as having run on the
|
|
19
|
+
loop.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
from collections import Counter
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from types import CodeType
|
|
30
|
+
|
|
31
|
+
from blockpath.runtime.predicate import on_event_loop
|
|
32
|
+
|
|
33
|
+
#: Default tool id. 0/1/2/5 are reserved for debugger / coverage / profiler / optimizer.
|
|
34
|
+
DEFAULT_TOOL_ID = 3
|
|
35
|
+
_FALLBACK_TOOL_IDS = (4, 3)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class Observation:
|
|
40
|
+
"""A project function seen entering while a loop ran in that thread."""
|
|
41
|
+
|
|
42
|
+
path: str
|
|
43
|
+
qualname: str
|
|
44
|
+
line: int
|
|
45
|
+
count: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MonitorError(RuntimeError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Monitor:
|
|
53
|
+
"""Records project functions that execute while an event loop runs in the same thread."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, project_root: Path, tool_id: int | None = None) -> None:
|
|
56
|
+
self._root = project_root.resolve()
|
|
57
|
+
self._requested_id = tool_id
|
|
58
|
+
self._tool_id: int | None = None
|
|
59
|
+
self._seen: Counter[tuple[str, str, int]] = Counter()
|
|
60
|
+
self._started = False
|
|
61
|
+
|
|
62
|
+
# -- lifecycle ----------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def start(self) -> None:
|
|
65
|
+
if self._started:
|
|
66
|
+
return
|
|
67
|
+
self._tool_id = self._claim_tool_id()
|
|
68
|
+
mon = sys.monitoring
|
|
69
|
+
mon.register_callback(self._tool_id, mon.events.PY_START, self._on_py_start)
|
|
70
|
+
mon.set_events(self._tool_id, mon.events.PY_START)
|
|
71
|
+
self._started = True
|
|
72
|
+
|
|
73
|
+
def stop(self) -> None:
|
|
74
|
+
if not self._started or self._tool_id is None:
|
|
75
|
+
return
|
|
76
|
+
mon = sys.monitoring
|
|
77
|
+
mon.set_events(self._tool_id, 0)
|
|
78
|
+
mon.register_callback(self._tool_id, mon.events.PY_START, None)
|
|
79
|
+
mon.free_tool_id(self._tool_id)
|
|
80
|
+
self._tool_id = None
|
|
81
|
+
self._started = False
|
|
82
|
+
|
|
83
|
+
def __enter__(self) -> Monitor:
|
|
84
|
+
self.start()
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, *exc: object) -> None:
|
|
88
|
+
self.stop()
|
|
89
|
+
|
|
90
|
+
def _claim_tool_id(self) -> int:
|
|
91
|
+
candidates = (
|
|
92
|
+
(self._requested_id,)
|
|
93
|
+
if self._requested_id is not None
|
|
94
|
+
else (DEFAULT_TOOL_ID, *_FALLBACK_TOOL_IDS)
|
|
95
|
+
)
|
|
96
|
+
for candidate in candidates:
|
|
97
|
+
if sys.monitoring.get_tool(candidate) is None:
|
|
98
|
+
sys.monitoring.use_tool_id(candidate, "blockpath")
|
|
99
|
+
return candidate
|
|
100
|
+
holders = {i: sys.monitoring.get_tool(i) for i in range(6)}
|
|
101
|
+
raise MonitorError(
|
|
102
|
+
f"no free sys.monitoring tool id; currently held: {holders}. "
|
|
103
|
+
"Another tool (a debugger, coverage in sysmon mode, a profiler) holds them all."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# -- the hot path -------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def _on_py_start(self, code: CodeType, instruction_offset: int) -> object:
|
|
109
|
+
if not self._is_project(code.co_filename):
|
|
110
|
+
# permanently stop events for this code object: third-party and stdlib cost one
|
|
111
|
+
# event each, once, and nothing afterwards
|
|
112
|
+
return sys.monitoring.DISABLE
|
|
113
|
+
if not on_event_loop():
|
|
114
|
+
# project code, but not on a loop right now; it may be later, so keep watching it
|
|
115
|
+
return None
|
|
116
|
+
self._seen[(code.co_filename, code.co_qualname, code.co_firstlineno)] += 1
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
def _is_project(self, filename: str) -> bool:
|
|
120
|
+
if not filename or filename.startswith("<"):
|
|
121
|
+
return False
|
|
122
|
+
try:
|
|
123
|
+
return Path(filename).resolve().is_relative_to(self._root)
|
|
124
|
+
except (OSError, ValueError):
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
# -- results ------------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def observations(self) -> list[Observation]:
|
|
131
|
+
out = [
|
|
132
|
+
Observation(
|
|
133
|
+
path=self._relative(path),
|
|
134
|
+
qualname=qualname,
|
|
135
|
+
line=line,
|
|
136
|
+
count=count,
|
|
137
|
+
)
|
|
138
|
+
for (path, qualname, line), count in sorted(self._seen.items())
|
|
139
|
+
]
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
def _relative(self, filename: str) -> str:
|
|
143
|
+
try:
|
|
144
|
+
return Path(filename).resolve().relative_to(self._root).as_posix()
|
|
145
|
+
except (OSError, ValueError):
|
|
146
|
+
return filename
|
|
147
|
+
|
|
148
|
+
def dump(self, path: Path) -> None:
|
|
149
|
+
payload = {
|
|
150
|
+
"version": 1,
|
|
151
|
+
"root": str(self._root),
|
|
152
|
+
"observations": [
|
|
153
|
+
{"path": o.path, "qualname": o.qualname, "line": o.line, "count": o.count}
|
|
154
|
+
for o in self.observations
|
|
155
|
+
],
|
|
156
|
+
}
|
|
157
|
+
path.write_text(json.dumps(payload, indent=2))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The one predicate the whole runtime layer rests on: is a loop running in *this* thread?
|
|
2
|
+
|
|
3
|
+
Three lines, and the reason they are these three lines rather than a walk over frame
|
|
4
|
+
``co_flags`` is worth stating, because the frame version looks equivalent and is not (ADR 0004):
|
|
5
|
+
|
|
6
|
+
* ``loop.call_soon(blocking_fn)`` runs a *synchronous* callback. There is no coroutine anywhere
|
|
7
|
+
on the stack, yet the loop is stalled for the duration. ``co_flags`` says "not async" and
|
|
8
|
+
misses it entirely -- and this is the single most common way a blocking call sneaks onto a
|
|
9
|
+
loop that someone has already audited for ``async def`` bodies.
|
|
10
|
+
* A coroutine running in *another* thread's loop puts ``CO_COROUTINE`` on that thread's stack.
|
|
11
|
+
It stalls that loop, not this one. ``co_flags`` cannot tell the threads apart.
|
|
12
|
+
* Synchronous code inside ``run_in_executor`` has a coroutine above it in the calling thread,
|
|
13
|
+
but executes in a worker thread where no loop runs. ``co_flags`` gives a false positive --
|
|
14
|
+
precisely the offloaded case the tool must stay quiet about.
|
|
15
|
+
|
|
16
|
+
``asyncio.get_running_loop()`` asks exactly the right question, and asks it per-thread, which is
|
|
17
|
+
what the question was in the first place. Frames are used only to report *where* something
|
|
18
|
+
happened, never to decide *whether* it was on the loop.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def on_event_loop() -> bool:
|
|
27
|
+
"""True when an asyncio event loop is running in the calling thread."""
|
|
28
|
+
try:
|
|
29
|
+
asyncio.get_running_loop()
|
|
30
|
+
except RuntimeError:
|
|
31
|
+
return False
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def running_loop_thread_id() -> int | None:
|
|
36
|
+
"""The id of the thread whose loop is running here, or None when there is no loop."""
|
|
37
|
+
import threading
|
|
38
|
+
|
|
39
|
+
return threading.get_ident() if on_event_loop() else None
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""``blockpath verify -- <command>`` and ``blockpath compare``.
|
|
2
|
+
|
|
3
|
+
``verify`` runs the project's own test suite (or any command) with the monitor attached, and
|
|
4
|
+
records which of the project's functions actually executed while a loop was running. The monitor
|
|
5
|
+
has to live *inside* that process, so a ``sitecustomize`` shim is put on ``PYTHONPATH`` -- the
|
|
6
|
+
same mechanism coverage uses to measure subprocesses -- and it dumps its observations at exit.
|
|
7
|
+
|
|
8
|
+
``compare`` puts the static findings next to those observations and sorts them into three piles:
|
|
9
|
+
|
|
10
|
+
* **confirmed** -- static said this function reaches a blocking call, and the runtime saw the
|
|
11
|
+
function run on the loop.
|
|
12
|
+
* **unexercised** -- static said so, but the tests never ran that function. This says nothing
|
|
13
|
+
about whether the finding is right; it says the suite does not cover it.
|
|
14
|
+
* **missed** -- the runtime saw a function run on the loop, that function contains a blocking
|
|
15
|
+
call, and static did *not* report it. Each of these is a proven hole in the analysis.
|
|
16
|
+
|
|
17
|
+
The ratio confirmed / (confirmed + missed) is a **lower bound on recall**, and nothing else. The
|
|
18
|
+
runtime only ever sees paths the tests happened to execute, so it can prove a miss and can never
|
|
19
|
+
prove completeness -- and it says nothing whatsoever about precision, which only hand
|
|
20
|
+
adjudication can establish.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import subprocess
|
|
28
|
+
import tempfile
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from blockpath.check import check
|
|
33
|
+
from blockpath.config import Config
|
|
34
|
+
from blockpath.model import Finding
|
|
35
|
+
|
|
36
|
+
_SHIM = '''\
|
|
37
|
+
"""Injected by `blockpath verify`: start the monitor before the target runs."""
|
|
38
|
+
import atexit
|
|
39
|
+
import os
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
from blockpath.runtime.monitor import Monitor
|
|
44
|
+
except Exception: # blockpath not importable in this interpreter: stay out of the way
|
|
45
|
+
pass
|
|
46
|
+
else:
|
|
47
|
+
_root = os.environ.get("BLOCKPATH_ROOT")
|
|
48
|
+
_out = os.environ.get("BLOCKPATH_OUT")
|
|
49
|
+
if _root and _out:
|
|
50
|
+
_monitor = Monitor(Path(_root))
|
|
51
|
+
try:
|
|
52
|
+
_monitor.start()
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
else:
|
|
56
|
+
@atexit.register
|
|
57
|
+
def _dump() -> None:
|
|
58
|
+
try:
|
|
59
|
+
_monitor.stop()
|
|
60
|
+
_monitor.dump(Path(_out))
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
'''
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class VerifyResult:
|
|
68
|
+
exit_code: int
|
|
69
|
+
observations_path: Path
|
|
70
|
+
observed: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run_verify(command: tuple[str, ...], root: Path, out_path: Path) -> VerifyResult:
|
|
74
|
+
"""Run ``command`` with the monitor attached; write observations to ``out_path``."""
|
|
75
|
+
root = root.resolve()
|
|
76
|
+
with tempfile.TemporaryDirectory(prefix="blockpath-shim-") as shim_dir:
|
|
77
|
+
(Path(shim_dir) / "sitecustomize.py").write_text(_SHIM)
|
|
78
|
+
env = os.environ.copy()
|
|
79
|
+
existing = env.get("PYTHONPATH", "")
|
|
80
|
+
env["PYTHONPATH"] = shim_dir + (os.pathsep + existing if existing else "")
|
|
81
|
+
env["BLOCKPATH_ROOT"] = str(root)
|
|
82
|
+
env["BLOCKPATH_OUT"] = str(out_path)
|
|
83
|
+
proc = subprocess.run(command, env=env, cwd=root, check=False)
|
|
84
|
+
observed = 0
|
|
85
|
+
if out_path.is_file():
|
|
86
|
+
try:
|
|
87
|
+
observed = len(json.loads(out_path.read_text()).get("observations", []))
|
|
88
|
+
except (OSError, json.JSONDecodeError):
|
|
89
|
+
observed = 0
|
|
90
|
+
return VerifyResult(exit_code=proc.returncode, observations_path=out_path, observed=observed)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True, slots=True)
|
|
94
|
+
class Comparison:
|
|
95
|
+
confirmed: tuple[Finding, ...]
|
|
96
|
+
unexercised: tuple[Finding, ...]
|
|
97
|
+
missed: tuple[tuple[str, str], ...] # (path, qualname) observed on the loop but not reported
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def recall_lower_bound(self) -> float | None:
|
|
101
|
+
denominator = len(self.confirmed) + len(self.missed)
|
|
102
|
+
if denominator == 0:
|
|
103
|
+
return None
|
|
104
|
+
return len(self.confirmed) / denominator
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _simple(qualname: str) -> str:
|
|
108
|
+
return qualname.rsplit(".", 1)[-1]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_observations(path: Path) -> set[tuple[str, str]]:
|
|
112
|
+
"""(relpath, simple function name) pairs seen executing while a loop ran."""
|
|
113
|
+
data = json.loads(path.read_text())
|
|
114
|
+
return {(item["path"], _simple(str(item["qualname"]))) for item in data.get("observations", [])}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def compare(root: Path, observations: set[tuple[str, str]], config: Config) -> Comparison:
|
|
118
|
+
"""Sort static findings against what the runtime actually saw execute.
|
|
119
|
+
|
|
120
|
+
Functions are matched on (relative path, simple function name). That is a heuristic: two
|
|
121
|
+
functions of the same name in one file collide. It is documented rather than hidden, and it
|
|
122
|
+
only ever affects which pile a finding lands in, never the finding itself.
|
|
123
|
+
"""
|
|
124
|
+
root = root.resolve()
|
|
125
|
+
result = check(root, config)
|
|
126
|
+
|
|
127
|
+
confirmed: list[Finding] = []
|
|
128
|
+
unexercised: list[Finding] = []
|
|
129
|
+
for finding in result.findings:
|
|
130
|
+
leaf = finding.leaf
|
|
131
|
+
rel = _rel(leaf.position.path, root)
|
|
132
|
+
name = _simple(leaf.qualname)
|
|
133
|
+
if (rel, name) in observations:
|
|
134
|
+
confirmed.append(finding)
|
|
135
|
+
else:
|
|
136
|
+
unexercised.append(finding)
|
|
137
|
+
|
|
138
|
+
reported = {
|
|
139
|
+
(_rel(f.leaf.position.path, root), _simple(f.leaf.qualname)) for f in result.findings
|
|
140
|
+
}
|
|
141
|
+
missed = tuple(sorted(observations - reported - _clean_functions(root, config, observations)))
|
|
142
|
+
return Comparison(tuple(confirmed), tuple(unexercised), missed)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _clean_functions(
|
|
146
|
+
root: Path, config: Config, observations: set[tuple[str, str]]
|
|
147
|
+
) -> set[tuple[str, str]]:
|
|
148
|
+
"""Observed functions that contain no blocking call at all, so their absence is correct."""
|
|
149
|
+
from blockpath.callgraph import EdgeKind, build_call_graph
|
|
150
|
+
from blockpath.collect import collect
|
|
151
|
+
from blockpath.oracle import Oracle
|
|
152
|
+
from blockpath.resolve import External, Resolver
|
|
153
|
+
from blockpath.symbols import analyze
|
|
154
|
+
|
|
155
|
+
collection = collect(root)
|
|
156
|
+
analyses = {m.qualname: analyze(m) for m in collection.modules}
|
|
157
|
+
resolver = Resolver(collection, {q: a.symbols for q, a in analyses.items()})
|
|
158
|
+
graph = build_call_graph(collection, analyses, resolver)
|
|
159
|
+
oracle = Oracle.load()
|
|
160
|
+
|
|
161
|
+
blocking_owners: set[tuple[str, str]] = set()
|
|
162
|
+
for edge in graph.edges:
|
|
163
|
+
if edge.kind is not EdgeKind.CALL or not isinstance(edge.target, External):
|
|
164
|
+
continue
|
|
165
|
+
if oracle.blocks(edge.target.dotted, config.enabled_tiers) is None:
|
|
166
|
+
continue
|
|
167
|
+
node = graph.nodes.get(edge.source)
|
|
168
|
+
if node is not None:
|
|
169
|
+
blocking_owners.add((_rel(node.position.path, root), _simple(edge.source)))
|
|
170
|
+
return observations - blocking_owners
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _rel(path: Path, root: Path) -> str:
|
|
174
|
+
try:
|
|
175
|
+
return path.resolve().relative_to(root).as_posix()
|
|
176
|
+
except ValueError:
|
|
177
|
+
return str(path)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""How long the event loop was actually stalled, and what it was doing at the time.
|
|
2
|
+
|
|
3
|
+
A thread outside the loop pings it with ``call_soon_threadsafe`` and times the reply. If the
|
|
4
|
+
reply does not come back within the lag threshold the loop is, by definition, busy doing
|
|
5
|
+
something synchronous -- so the watchdog snapshots the *loop thread's* stack right then, while
|
|
6
|
+
the blocking call is still on it. That stack is the evidence: it names the file and line that is
|
|
7
|
+
holding the loop, which is exactly the claim the static analysis makes in advance.
|
|
8
|
+
|
|
9
|
+
This is the "impact" half of the runtime layer. It answers "the loop stalled 812 ms, here", where
|
|
10
|
+
the monitor answers "this function ran while a loop was running". Neither substitutes for the
|
|
11
|
+
other, and neither measures precision -- see the recall caveat in the package docstring.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import traceback
|
|
20
|
+
from asyncio import AbstractEventLoop
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
#: How often the watchdog pings the loop.
|
|
24
|
+
DEFAULT_PING_INTERVAL = 0.010
|
|
25
|
+
#: A reply slower than this means the loop was busy; snapshot it.
|
|
26
|
+
DEFAULT_LAG_THRESHOLD = 0.050
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class Stall:
|
|
31
|
+
"""One observed stall: how long the loop failed to answer, and where it was."""
|
|
32
|
+
|
|
33
|
+
duration: float
|
|
34
|
+
#: innermost-last frames of the loop thread, captured while it was still blocked
|
|
35
|
+
stack: tuple[str, ...] = field(default_factory=tuple)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def where(self) -> str:
|
|
39
|
+
return self.stack[-1] if self.stack else "<unknown>"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LoopWatchdog:
|
|
43
|
+
"""Pings a running loop from another thread and records how long replies take.
|
|
44
|
+
|
|
45
|
+
Thresholds are constructor arguments rather than constants so a slow CI box or a chatty
|
|
46
|
+
service can be tuned without editing the source; the defaults (ping every 10 ms, complain
|
|
47
|
+
past 50 ms) are chosen so ordinary scheduling jitter does not register as a stall.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
loop: AbstractEventLoop,
|
|
53
|
+
*,
|
|
54
|
+
ping_interval: float = DEFAULT_PING_INTERVAL,
|
|
55
|
+
lag_threshold: float = DEFAULT_LAG_THRESHOLD,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._loop = loop
|
|
58
|
+
self._ping_interval = ping_interval
|
|
59
|
+
self._lag_threshold = lag_threshold
|
|
60
|
+
self._stop = threading.Event()
|
|
61
|
+
self._thread: threading.Thread | None = None
|
|
62
|
+
self._loop_thread_id: int | None = None
|
|
63
|
+
self.stalls: list[Stall] = []
|
|
64
|
+
|
|
65
|
+
def start(self) -> None:
|
|
66
|
+
# Learn which thread the loop runs on, so the snapshot targets the right stack.
|
|
67
|
+
done = threading.Event()
|
|
68
|
+
|
|
69
|
+
def note_thread() -> None:
|
|
70
|
+
self._loop_thread_id = threading.get_ident()
|
|
71
|
+
done.set()
|
|
72
|
+
|
|
73
|
+
self._loop.call_soon_threadsafe(note_thread)
|
|
74
|
+
done.wait(timeout=1.0)
|
|
75
|
+
|
|
76
|
+
self._thread = threading.Thread(target=self._run, name="blockpath-watchdog", daemon=True)
|
|
77
|
+
self._thread.start()
|
|
78
|
+
|
|
79
|
+
def stop(self) -> None:
|
|
80
|
+
self._stop.set()
|
|
81
|
+
if self._thread is not None:
|
|
82
|
+
self._thread.join(timeout=1.0)
|
|
83
|
+
self._thread = None
|
|
84
|
+
|
|
85
|
+
def __enter__(self) -> LoopWatchdog:
|
|
86
|
+
self.start()
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def __exit__(self, *exc: object) -> None:
|
|
90
|
+
self.stop()
|
|
91
|
+
|
|
92
|
+
def _run(self) -> None:
|
|
93
|
+
while not self._stop.is_set():
|
|
94
|
+
answered = threading.Event()
|
|
95
|
+
sent = time.perf_counter()
|
|
96
|
+
try:
|
|
97
|
+
self._loop.call_soon_threadsafe(answered.set)
|
|
98
|
+
except RuntimeError:
|
|
99
|
+
return # loop closed underneath us; nothing left to watch
|
|
100
|
+
if not answered.wait(self._lag_threshold):
|
|
101
|
+
# The loop owes us a reply and has not sent it: it is blocked right now, so this
|
|
102
|
+
# is the one moment the offending frame is still on its stack.
|
|
103
|
+
stack = self._snapshot()
|
|
104
|
+
answered.wait(timeout=30.0)
|
|
105
|
+
self.stalls.append(Stall(duration=time.perf_counter() - sent, stack=stack))
|
|
106
|
+
self._stop.wait(self._ping_interval)
|
|
107
|
+
|
|
108
|
+
def _snapshot(self) -> tuple[str, ...]:
|
|
109
|
+
if self._loop_thread_id is None:
|
|
110
|
+
return ()
|
|
111
|
+
frame = sys._current_frames().get(self._loop_thread_id)
|
|
112
|
+
if frame is None:
|
|
113
|
+
return ()
|
|
114
|
+
return tuple(
|
|
115
|
+
f"{entry.filename}:{entry.lineno} {entry.name}"
|
|
116
|
+
for entry in traceback.extract_stack(frame)
|
|
117
|
+
)
|