concurim 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.
- concurim/__init__.py +72 -0
- concurim/__main__.py +7 -0
- concurim/cli.py +262 -0
- concurim/config.py +81 -0
- concurim/export/__init__.py +1 -0
- concurim/export/cdp.py +224 -0
- concurim/export/cli.py +110 -0
- concurim/export/options.py +99 -0
- concurim/export/sink.py +126 -0
- concurim/export/video.py +136 -0
- concurim/instrumentation/__init__.py +1 -0
- concurim/instrumentation/aio_events.py +158 -0
- concurim/instrumentation/aio_primitives.py +334 -0
- concurim/instrumentation/aio_taskgroup.py +87 -0
- concurim/instrumentation/aio_tasks.py +152 -0
- concurim/instrumentation/auto.py +251 -0
- concurim/instrumentation/auto_events.py +56 -0
- concurim/instrumentation/auto_mp.py +33 -0
- concurim/instrumentation/auto_support.py +46 -0
- concurim/instrumentation/barriers.py +213 -0
- concurim/instrumentation/executors.py +144 -0
- concurim/instrumentation/futures_support.py +132 -0
- concurim/instrumentation/mp_pool.py +272 -0
- concurim/instrumentation/mp_process.py +113 -0
- concurim/instrumentation/mp_support.py +268 -0
- concurim/instrumentation/mp_tasks.py +70 -0
- concurim/instrumentation/process_pool.py +164 -0
- concurim/instrumentation/queue_support.py +145 -0
- concurim/instrumentation/queues.py +263 -0
- concurim/instrumentation/sync.py +294 -0
- concurim/instrumentation/sync_events.py +179 -0
- concurim/instrumentation/threads.py +92 -0
- concurim/instrumentation/work.py +65 -0
- concurim/program_check.py +47 -0
- concurim/py.typed +0 -0
- concurim/recorder/__init__.py +1 -0
- concurim/recorder/collector.py +351 -0
- concurim/recorder/environment.py +110 -0
- concurim/recorder/events.py +246 -0
- concurim/recorder/flush.py +143 -0
- concurim/recorder/identities.py +27 -0
- concurim/recorder/lifecycle.py +211 -0
- concurim/recorder/sampler.py +192 -0
- concurim/recorder/serialization.py +159 -0
- concurim/replay/__init__.py +1 -0
- concurim/replay/code_view.py +71 -0
- concurim/replay/engine.py +218 -0
- concurim/replay/explanations.py +141 -0
- concurim/replay/models.py +124 -0
- concurim/replay/rules_barrier.py +83 -0
- concurim/replay/rules_events.py +191 -0
- concurim/replay/rules_exec.py +178 -0
- concurim/replay/rules_queues.py +200 -0
- concurim/replay/rules_sync.py +79 -0
- concurim/replay/rules_threads.py +101 -0
- concurim/replay/spans.py +128 -0
- concurim/replay/state.py +323 -0
- concurim/replay/state_barrier.py +79 -0
- concurim/replay/state_events.py +108 -0
- concurim/replay/state_exec.py +174 -0
- concurim/replay/state_sync.py +88 -0
- concurim/replay/state_taskgroup.py +70 -0
- concurim/replay/wait_for.py +206 -0
- concurim/runner.py +90 -0
- concurim/samples/__init__.py +32 -0
- concurim/samples/asyncio-condition.concurim +0 -0
- concurim/samples/asyncio-queue.concurim +0 -0
- concurim/samples/asyncio-tasks.concurim +0 -0
- concurim/samples/asyncio_condition.py +40 -0
- concurim/samples/asyncio_queue.py +42 -0
- concurim/samples/asyncio_tasks.py +25 -0
- concurim/samples/backpressure.concurim +0 -0
- concurim/samples/backpressure.py +48 -0
- concurim/samples/barrier.concurim +0 -0
- concurim/samples/barrier.py +31 -0
- concurim/samples/condition-mailbox.concurim +0 -0
- concurim/samples/condition_mailbox.py +47 -0
- concurim/samples/cpu-threads.concurim +0 -0
- concurim/samples/cpu_threads.py +33 -0
- concurim/samples/event-gate.concurim +0 -0
- concurim/samples/event_gate.py +37 -0
- concurim/samples/future-control.concurim +0 -0
- concurim/samples/future_control.py +42 -0
- concurim/samples/lineage-pipeline.concurim +0 -0
- concurim/samples/lineage_pipeline.py +70 -0
- concurim/samples/lock-contention.concurim +0 -0
- concurim/samples/lock-order.concurim +0 -0
- concurim/samples/lock_contention.py +40 -0
- concurim/samples/lock_order.py +43 -0
- concurim/samples/manifest.json +298 -0
- concurim/samples/plain-queues.concurim +0 -0
- concurim/samples/plain-threads.concurim +0 -0
- concurim/samples/plain_pipeline.py +61 -0
- concurim/samples/process-pool-map.concurim +0 -0
- concurim/samples/process-pool.concurim +0 -0
- concurim/samples/process-share.concurim +0 -0
- concurim/samples/process_pool.py +31 -0
- concurim/samples/process_pool_map.py +27 -0
- concurim/samples/process_share.py +33 -0
- concurim/samples/producer-consumer.concurim +0 -0
- concurim/samples/semaphore.concurim +0 -0
- concurim/samples/semaphore_limit.py +34 -0
- concurim/samples/shutdown.concurim +0 -0
- concurim/samples/shutdown.py +44 -0
- concurim/samples/task-group.concurim +0 -0
- concurim/samples/task_group.py +30 -0
- concurim/samples/thread-pool.concurim +0 -0
- concurim/samples/thread_pool.py +30 -0
- concurim/samples/threaded_pipeline.py +72 -0
- concurim/schema/__init__.py +0 -0
- concurim/schema/event.schema.json +42 -0
- concurim/viewer/__init__.py +0 -0
- concurim/viewer/api.py +256 -0
- concurim/viewer/exports.py +197 -0
- concurim/viewer/interpreters.py +86 -0
- concurim/viewer/plan.py +52 -0
- concurim/viewer/procs.py +62 -0
- concurim/viewer/runs.py +273 -0
- concurim/viewer/server.py +197 -0
- concurim/viewer/session.py +43 -0
- concurim/viewer/static/assets/Viewer-B5HA3A02.js +8 -0
- concurim/viewer/static/assets/Viewer-DLioOiRN.css +1 -0
- concurim/viewer/static/assets/index-BHRQ-DHX.css +1 -0
- concurim/viewer/static/assets/index-Ch-8w8yu.js +11 -0
- concurim/viewer/static/assets/rolldown-runtime-WkwZ5CU-.js +1 -0
- concurim/viewer/static/index.html +16 -0
- concurim/viewer/static/logo.png +0 -0
- concurim/viewer/workspaces.py +144 -0
- concurim-0.1.0.dist-info/METADATA +95 -0
- concurim-0.1.0.dist-info/RECORD +133 -0
- concurim-0.1.0.dist-info/WHEEL +4 -0
- concurim-0.1.0.dist-info/entry_points.txt +2 -0
- concurim-0.1.0.dist-info/licenses/LICENSE +21 -0
concurim/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Concurim: see how Python runs together."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
from concurim.config import RecorderConfig
|
|
8
|
+
from concurim.instrumentation.aio_events import (
|
|
9
|
+
TracedAsyncCondition,
|
|
10
|
+
TracedAsyncEvent,
|
|
11
|
+
)
|
|
12
|
+
from concurim.instrumentation.aio_primitives import (
|
|
13
|
+
TracedAsyncBoundedSemaphore,
|
|
14
|
+
TracedAsyncLock,
|
|
15
|
+
TracedAsyncQueue,
|
|
16
|
+
TracedAsyncSemaphore,
|
|
17
|
+
)
|
|
18
|
+
from concurim.instrumentation.barriers import (
|
|
19
|
+
TracedAsyncBarrier,
|
|
20
|
+
TracedBarrier,
|
|
21
|
+
)
|
|
22
|
+
from concurim.instrumentation.executors import TracedThreadPoolExecutor
|
|
23
|
+
from concurim.instrumentation.mp_pool import TracedPool
|
|
24
|
+
from concurim.instrumentation.mp_process import TracedProcess
|
|
25
|
+
from concurim.instrumentation.process_pool import TracedProcessPoolExecutor
|
|
26
|
+
from concurim.instrumentation.queues import QueueItem, TracedQueue
|
|
27
|
+
from concurim.instrumentation.sync import (
|
|
28
|
+
TracedBoundedSemaphore,
|
|
29
|
+
TracedLock,
|
|
30
|
+
TracedRLock,
|
|
31
|
+
TracedSemaphore,
|
|
32
|
+
)
|
|
33
|
+
from concurim.instrumentation.sync_events import (
|
|
34
|
+
TracedCondition,
|
|
35
|
+
TracedEvent,
|
|
36
|
+
)
|
|
37
|
+
from concurim.instrumentation.threads import TracedThread
|
|
38
|
+
from concurim.instrumentation.work import work
|
|
39
|
+
from concurim.recorder.lifecycle import active_recorder, record
|
|
40
|
+
from concurim.recorder.serialization import load_trace
|
|
41
|
+
from concurim.replay.engine import build_replay
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"QueueItem",
|
|
45
|
+
"RecorderConfig",
|
|
46
|
+
"TracedAsyncBarrier",
|
|
47
|
+
"TracedAsyncBoundedSemaphore",
|
|
48
|
+
"TracedAsyncCondition",
|
|
49
|
+
"TracedAsyncEvent",
|
|
50
|
+
"TracedAsyncLock",
|
|
51
|
+
"TracedAsyncQueue",
|
|
52
|
+
"TracedAsyncSemaphore",
|
|
53
|
+
"TracedBarrier",
|
|
54
|
+
"TracedBoundedSemaphore",
|
|
55
|
+
"TracedCondition",
|
|
56
|
+
"TracedEvent",
|
|
57
|
+
"TracedLock",
|
|
58
|
+
"TracedQueue",
|
|
59
|
+
"TracedRLock",
|
|
60
|
+
"TracedSemaphore",
|
|
61
|
+
"TracedThread",
|
|
62
|
+
"TracedPool",
|
|
63
|
+
"TracedProcess",
|
|
64
|
+
"TracedProcessPoolExecutor",
|
|
65
|
+
"TracedThreadPoolExecutor",
|
|
66
|
+
"__version__",
|
|
67
|
+
"active_recorder",
|
|
68
|
+
"build_replay",
|
|
69
|
+
"load_trace",
|
|
70
|
+
"record",
|
|
71
|
+
"work",
|
|
72
|
+
]
|
concurim/__main__.py
ADDED
concurim/cli.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Command-line interface: record, view, demo, inspect."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import webbrowser
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from concurim import __version__
|
|
14
|
+
from concurim.config import RecorderConfig
|
|
15
|
+
from concurim.export import cli as export_cli
|
|
16
|
+
from concurim.instrumentation.auto import LEVELS
|
|
17
|
+
from concurim.program_check import ProgramError
|
|
18
|
+
from concurim.recorder.events import TraceFormatError
|
|
19
|
+
from concurim.recorder.serialization import Trace, load_trace
|
|
20
|
+
from concurim.samples import sample_file
|
|
21
|
+
|
|
22
|
+
SHARING_WARNING = (
|
|
23
|
+
"Note: traces can contain file paths, thread names and (if enabled) "
|
|
24
|
+
"source code or payload previews. Review a trace before sharing it."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
parser = argparse.ArgumentParser(
|
|
30
|
+
prog="concurim", description="See how Python runs together."
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--version", action="version", version=f"concurim {__version__}"
|
|
34
|
+
)
|
|
35
|
+
_add_server_options(parser)
|
|
36
|
+
parser.set_defaults(func=_cmd_home)
|
|
37
|
+
sub = parser.add_subparsers(dest="command")
|
|
38
|
+
|
|
39
|
+
rec = sub.add_parser(
|
|
40
|
+
"record",
|
|
41
|
+
help="run a script and record instrumented activity",
|
|
42
|
+
description="Run SCRIPT and record activity on Concurim-instrumented "
|
|
43
|
+
"objects. Options must come before SCRIPT; everything after SCRIPT "
|
|
44
|
+
"is passed to it unchanged.",
|
|
45
|
+
)
|
|
46
|
+
rec.add_argument(
|
|
47
|
+
"-o",
|
|
48
|
+
"--output",
|
|
49
|
+
type=Path,
|
|
50
|
+
help="trace path (default: <script>.concurim)",
|
|
51
|
+
)
|
|
52
|
+
rec.add_argument(
|
|
53
|
+
"--include-source",
|
|
54
|
+
action="store_true",
|
|
55
|
+
help="embed project source files in the trace",
|
|
56
|
+
)
|
|
57
|
+
rec.add_argument(
|
|
58
|
+
"--preview",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="record bounded previews of primitive payloads",
|
|
61
|
+
)
|
|
62
|
+
rec.add_argument(
|
|
63
|
+
"--auto",
|
|
64
|
+
choices=LEVELS,
|
|
65
|
+
default="threads",
|
|
66
|
+
help="automatic instrumentation: none (only Concurim objects), "
|
|
67
|
+
"threads (default), queues (threads + queue.Queue)",
|
|
68
|
+
)
|
|
69
|
+
rec.add_argument("--max-events", type=int, default=1_000_000)
|
|
70
|
+
rec.add_argument(
|
|
71
|
+
"--light",
|
|
72
|
+
action="store_true",
|
|
73
|
+
help="lightweight mode: no source locations",
|
|
74
|
+
)
|
|
75
|
+
rec.add_argument(
|
|
76
|
+
"--open",
|
|
77
|
+
action="store_true",
|
|
78
|
+
help="open the viewer when the recording finishes",
|
|
79
|
+
)
|
|
80
|
+
_add_server_options(rec)
|
|
81
|
+
rec.add_argument("script", type=Path)
|
|
82
|
+
rec.add_argument("script_args", nargs=argparse.REMAINDER)
|
|
83
|
+
rec.set_defaults(func=_cmd_record)
|
|
84
|
+
|
|
85
|
+
for name, func, help_ in (
|
|
86
|
+
("view", _cmd_view, "open a recorded trace"),
|
|
87
|
+
("compare", _cmd_compare, "show two recordings side by side"),
|
|
88
|
+
("demo", _cmd_demo, "record and view a demo"),
|
|
89
|
+
):
|
|
90
|
+
cmd = sub.add_parser(name, help=help_)
|
|
91
|
+
if name == "view":
|
|
92
|
+
cmd.add_argument("trace", type=Path)
|
|
93
|
+
if name == "compare":
|
|
94
|
+
cmd.add_argument("first", type=Path)
|
|
95
|
+
cmd.add_argument("second", type=Path)
|
|
96
|
+
_add_server_options(cmd)
|
|
97
|
+
cmd.set_defaults(func=func)
|
|
98
|
+
|
|
99
|
+
export_cli.add_parser(sub)
|
|
100
|
+
insp = sub.add_parser("inspect", help="print trace metadata")
|
|
101
|
+
insp.add_argument("trace", type=Path)
|
|
102
|
+
insp.set_defaults(func=_cmd_inspect)
|
|
103
|
+
return parser
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _add_server_options(parser: argparse.ArgumentParser) -> None:
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
"--port",
|
|
109
|
+
type=int,
|
|
110
|
+
default=0,
|
|
111
|
+
help="port to serve on (default: any free port)",
|
|
112
|
+
)
|
|
113
|
+
parser.add_argument("--no-browser", action="store_true")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
117
|
+
"""CLI entry point; returns the process exit status."""
|
|
118
|
+
args = build_parser().parse_args(argv)
|
|
119
|
+
try:
|
|
120
|
+
return args.func(args)
|
|
121
|
+
except (TraceFormatError, FileNotFoundError, ProgramError) as error:
|
|
122
|
+
print(f"concurim: error: {error}", file=sys.stderr)
|
|
123
|
+
return 2
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _cmd_record(args: argparse.Namespace) -> int:
|
|
127
|
+
from concurim.runner import run_recorded
|
|
128
|
+
|
|
129
|
+
script: Path = args.script
|
|
130
|
+
if not script.is_file():
|
|
131
|
+
raise FileNotFoundError(f"script not found: {script}")
|
|
132
|
+
output = args.output or Path(f"{script.stem}.concurim")
|
|
133
|
+
config = RecorderConfig(
|
|
134
|
+
max_events=args.max_events,
|
|
135
|
+
include_source=args.include_source,
|
|
136
|
+
preview=args.preview,
|
|
137
|
+
capture_source_locations=not args.light,
|
|
138
|
+
auto=args.auto,
|
|
139
|
+
# Paths are shown relative to the script, wherever it is run from.
|
|
140
|
+
source_root=str(script.resolve().parent),
|
|
141
|
+
)
|
|
142
|
+
code, count = run_recorded(
|
|
143
|
+
script, args.script_args, output, config, view_on_stop=args.open
|
|
144
|
+
)
|
|
145
|
+
print(f"concurim: recorded {count} events to {output}", file=sys.stderr)
|
|
146
|
+
print(f"concurim: view it with: concurim view {output}", file=sys.stderr)
|
|
147
|
+
print(SHARING_WARNING, file=sys.stderr)
|
|
148
|
+
stuck = _still_blocked(output)
|
|
149
|
+
if args.open and output.is_file():
|
|
150
|
+
_serve(
|
|
151
|
+
load_trace(output),
|
|
152
|
+
args.port,
|
|
153
|
+
not args.no_browser,
|
|
154
|
+
output.name,
|
|
155
|
+
)
|
|
156
|
+
if stuck:
|
|
157
|
+
# Non-daemon threads are still blocked. Returning would hang in
|
|
158
|
+
# interpreter shutdown and never give the terminal back.
|
|
159
|
+
os._exit(code)
|
|
160
|
+
return code
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _serve(
|
|
164
|
+
trace: Trace | None,
|
|
165
|
+
port: int,
|
|
166
|
+
open_browser: bool,
|
|
167
|
+
label: str = "trace",
|
|
168
|
+
compare: tuple[tuple[Trace, str], ...] = (),
|
|
169
|
+
) -> int:
|
|
170
|
+
from concurim.viewer.server import ViewerServer
|
|
171
|
+
|
|
172
|
+
if not (Path(__file__).parent / "viewer/static/index.html").is_file():
|
|
173
|
+
raise FileNotFoundError("viewer assets missing (run scripts/build.sh)")
|
|
174
|
+
server = ViewerServer(trace, port, label=label, compare=compare)
|
|
175
|
+
url = server.url + ("?compare=1" if compare else "")
|
|
176
|
+
print(f"concurim: viewer at {url} (Ctrl-C to stop)")
|
|
177
|
+
if open_browser:
|
|
178
|
+
webbrowser.open(url)
|
|
179
|
+
try:
|
|
180
|
+
server.serve_forever()
|
|
181
|
+
except KeyboardInterrupt:
|
|
182
|
+
print("\nconcurim: stopped")
|
|
183
|
+
finally:
|
|
184
|
+
server.server_close()
|
|
185
|
+
return 0
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _cmd_home(args: argparse.Namespace) -> int:
|
|
189
|
+
return _serve(None, args.port, not args.no_browser)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _cmd_view(args: argparse.Namespace) -> int:
|
|
193
|
+
return _serve(
|
|
194
|
+
load_trace(args.trace), args.port, not args.no_browser, args.trace.name
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _cmd_compare(args: argparse.Namespace) -> int:
|
|
199
|
+
pair = tuple((load_trace(p), p.name) for p in (args.first, args.second))
|
|
200
|
+
return _serve(None, args.port, not args.no_browser, compare=pair)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _cmd_demo(args: argparse.Namespace) -> int:
|
|
204
|
+
from concurim.runner import run_recorded
|
|
205
|
+
|
|
206
|
+
script = sample_file("threaded_pipeline.py")
|
|
207
|
+
out = Path(tempfile.mkdtemp(prefix="concurim-demo-")) / "demo.concurim"
|
|
208
|
+
# The bundled example is trusted, so its source is embedded.
|
|
209
|
+
config = RecorderConfig(include_source=True, source_root=str(script.parent))
|
|
210
|
+
code, count = run_recorded(script, [], out, config)
|
|
211
|
+
print(f"concurim: recorded {count} events from {script.name}")
|
|
212
|
+
if code != 0:
|
|
213
|
+
return code
|
|
214
|
+
return _serve(load_trace(out), args.port, not args.no_browser, script.name)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _still_blocked(path: Path) -> bool:
|
|
218
|
+
"""Whether the written trace says other threads were still running."""
|
|
219
|
+
if not path.is_file():
|
|
220
|
+
return False
|
|
221
|
+
try:
|
|
222
|
+
program = load_trace(path).metadata.get("program") or {}
|
|
223
|
+
except (TraceFormatError, OSError):
|
|
224
|
+
return False
|
|
225
|
+
return bool(program.get("still_blocked"))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _cmd_inspect(args: argparse.Namespace) -> int:
|
|
229
|
+
trace = load_trace(args.trace)
|
|
230
|
+
meta = trace.metadata
|
|
231
|
+
env = meta.get("environment", {})
|
|
232
|
+
print(f"trace: {args.trace}")
|
|
233
|
+
print(
|
|
234
|
+
f"concurim: {meta.get('concurim_version')} "
|
|
235
|
+
f"(format {meta.get('format_version')})"
|
|
236
|
+
)
|
|
237
|
+
print(f"recorded: {meta.get('created_at_utc')}")
|
|
238
|
+
print(f"duration: {meta.get('duration_ns', 0) / 1e9:.3f}s")
|
|
239
|
+
print(f"events: {len(trace.events)}")
|
|
240
|
+
print(
|
|
241
|
+
f"python: {env.get('implementation')} "
|
|
242
|
+
f"{env.get('python_version')} on {env.get('platform')} "
|
|
243
|
+
f"(free-threaded build: {env.get('free_threaded_build')}, "
|
|
244
|
+
f"GIL enabled: {env.get('gil_enabled')})"
|
|
245
|
+
)
|
|
246
|
+
print(f"sources: {len(trace.sources)} embedded")
|
|
247
|
+
program = meta.get("program") or {}
|
|
248
|
+
if program.get("outcome"):
|
|
249
|
+
print(f"outcome: {program['outcome']}")
|
|
250
|
+
if program.get("still_blocked"):
|
|
251
|
+
print("WARNING: stopped while other threads were still running")
|
|
252
|
+
if meta.get("truncated"):
|
|
253
|
+
print(
|
|
254
|
+
f"WARNING: truncated, {meta.get('dropped_events')} "
|
|
255
|
+
"events dropped (buffer limit)"
|
|
256
|
+
)
|
|
257
|
+
coverage = meta.get("coverage", {})
|
|
258
|
+
for key in ("observed", "not_observed"):
|
|
259
|
+
print(f"{key.replace('_', ' ')}:")
|
|
260
|
+
for line in coverage.get(key, []):
|
|
261
|
+
print(f" - {line}")
|
|
262
|
+
return 0
|
concurim/config.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Recorder configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
# Automatic instrumentation levels; each includes the ones before it.
|
|
9
|
+
LEVELS = ("none", "threads", "queues", "sync", "asyncio")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class RecorderConfig:
|
|
14
|
+
"""Options controlling what is recorded.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
max_events: Hard cap on buffered events. When reached, *new* events
|
|
18
|
+
are dropped (the earliest events keep the trace coherent) and the
|
|
19
|
+
trace is marked truncated.
|
|
20
|
+
include_source: Embed the text of referenced source files in the
|
|
21
|
+
trace. Off by default because sources may be sensitive.
|
|
22
|
+
capture_source_locations: Record file/line/function per event. Turn
|
|
23
|
+
off for the lightest recording mode.
|
|
24
|
+
preview: Record a bounded preview of primitive payloads. Off by
|
|
25
|
+
default because payloads may be sensitive.
|
|
26
|
+
preview_max_chars: Maximum characters kept in a preview.
|
|
27
|
+
redact_patterns: Regexes replaced by ``[redacted]`` in previews.
|
|
28
|
+
auto: Automatic instrumentation level while recording, each level
|
|
29
|
+
including the previous: ``"none"`` (only Concurim-managed
|
|
30
|
+
objects), ``"threads"`` (every ``threading.Thread``),
|
|
31
|
+
``"queues"`` (``queue.Queue`` objects created during the run),
|
|
32
|
+
``"sync"`` (locks, semaphores and ``ThreadPoolExecutor``
|
|
33
|
+
created by your code) or ``"asyncio"`` (tasks and asyncio
|
|
34
|
+
queues, locks and semaphores).
|
|
35
|
+
source_root: Directory that recorded paths are shown relative to,
|
|
36
|
+
and the only place source files may be embedded from. Defaults
|
|
37
|
+
to the current directory when recording starts.
|
|
38
|
+
sample_interval_ms: How often (milliseconds) a background thread
|
|
39
|
+
samples which line each worker is running, so the code view can
|
|
40
|
+
follow the work between instrumented calls. Only used with
|
|
41
|
+
``include_source``; ``0`` turns sampling off. Sampling is
|
|
42
|
+
approximate and slightly slows the program.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
max_events: int = 1_000_000
|
|
46
|
+
include_source: bool = False
|
|
47
|
+
capture_source_locations: bool = True
|
|
48
|
+
preview: bool = False
|
|
49
|
+
preview_max_chars: int = 48
|
|
50
|
+
redact_patterns: tuple[str, ...] = ()
|
|
51
|
+
source_root: str | None = None
|
|
52
|
+
auto: str = "none"
|
|
53
|
+
sample_interval_ms: float = 5.0
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
if self.auto not in LEVELS:
|
|
57
|
+
raise ValueError(f"unknown auto level {self.auto!r}")
|
|
58
|
+
if self.max_events < 1:
|
|
59
|
+
raise ValueError("max_events must be positive")
|
|
60
|
+
if not 0 <= self.preview_max_chars <= 256:
|
|
61
|
+
raise ValueError("preview_max_chars must be in [0, 256]")
|
|
62
|
+
if not 0 <= self.sample_interval_ms <= 10_000:
|
|
63
|
+
raise ValueError("sample_interval_ms must be in [0, 10000]")
|
|
64
|
+
for pattern in self.redact_patterns:
|
|
65
|
+
re.compile(pattern)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def light(cls) -> RecorderConfig:
|
|
69
|
+
"""Lightweight mode: no source locations, no previews."""
|
|
70
|
+
return cls(capture_source_locations=False)
|
|
71
|
+
|
|
72
|
+
def public_dict(self) -> dict[str, object]:
|
|
73
|
+
"""Config values safe to store in trace metadata."""
|
|
74
|
+
return {
|
|
75
|
+
"max_events": self.max_events,
|
|
76
|
+
"include_source": self.include_source,
|
|
77
|
+
"capture_source_locations": self.capture_source_locations,
|
|
78
|
+
"preview": self.preview,
|
|
79
|
+
"auto": self.auto,
|
|
80
|
+
"sample_interval_ms": self.sample_interval_ms,
|
|
81
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Render recordings as video, GIF or PNG frames (see ``video.py``)."""
|
concurim/export/cdp.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""A tiny Chrome DevTools Protocol client over a pipe (standard library).
|
|
2
|
+
|
|
3
|
+
Chrome-family browsers started with ``--remote-debugging-pipe`` read JSON
|
|
4
|
+
commands from file descriptor 3 and answer on file descriptor 4, each
|
|
5
|
+
message ending in a NUL byte. That is all the exporter needs, so no browser
|
|
6
|
+
automation package is required. POSIX only (macOS and Linux).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import contextlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import select
|
|
16
|
+
import shutil
|
|
17
|
+
import signal
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
BROWSER_ENV = "CONCURIM_BROWSER"
|
|
24
|
+
_MAC_APPS = (
|
|
25
|
+
"Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
26
|
+
"Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
27
|
+
"Chromium.app/Contents/MacOS/Chromium",
|
|
28
|
+
"Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
29
|
+
)
|
|
30
|
+
_PATH_NAMES = (
|
|
31
|
+
"google-chrome",
|
|
32
|
+
"google-chrome-stable",
|
|
33
|
+
"chromium",
|
|
34
|
+
"chromium-browser",
|
|
35
|
+
"microsoft-edge",
|
|
36
|
+
"microsoft-edge-stable",
|
|
37
|
+
"brave-browser",
|
|
38
|
+
)
|
|
39
|
+
_TIMEOUT_S = 30.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ExportError(Exception):
|
|
43
|
+
"""A problem the user can fix (missing browser, missing ffmpeg, ...)."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def find_browser() -> str:
|
|
47
|
+
"""Path of a Chrome-family browser.
|
|
48
|
+
|
|
49
|
+
``$CONCURIM_BROWSER`` wins; otherwise common install locations and
|
|
50
|
+
``PATH`` are searched. Raises ``ExportError`` with advice if none exists.
|
|
51
|
+
"""
|
|
52
|
+
wanted = os.environ.get(BROWSER_ENV)
|
|
53
|
+
if wanted:
|
|
54
|
+
if shutil.which(wanted) or Path(wanted).is_file():
|
|
55
|
+
return shutil.which(wanted) or wanted
|
|
56
|
+
raise ExportError(f"{BROWSER_ENV}={wanted!r} is not an executable")
|
|
57
|
+
for app in _MAC_APPS:
|
|
58
|
+
for base in (Path("/Applications"), Path.home() / "Applications"):
|
|
59
|
+
if (base / app).is_file():
|
|
60
|
+
return str(base / app)
|
|
61
|
+
for name in _PATH_NAMES:
|
|
62
|
+
found = shutil.which(name)
|
|
63
|
+
if found:
|
|
64
|
+
return found
|
|
65
|
+
raise ExportError(
|
|
66
|
+
"no Chrome-family browser found. Install Google Chrome, Chromium "
|
|
67
|
+
f"or Microsoft Edge, or set {BROWSER_ENV} to its executable."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _require_posix() -> None:
|
|
72
|
+
"""Raise if video export cannot run on this operating system."""
|
|
73
|
+
if sys.platform == "win32":
|
|
74
|
+
raise ExportError("video export needs macOS or Linux")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _spawn_browser(
|
|
78
|
+
executable: str,
|
|
79
|
+
args: list[str],
|
|
80
|
+
to_browser_r: int,
|
|
81
|
+
from_browser_w: int,
|
|
82
|
+
) -> int:
|
|
83
|
+
"""Start ``executable`` with the DevTools pipe on fds 3 and 4.
|
|
84
|
+
|
|
85
|
+
``to_browser_r`` becomes fd 3 and ``from_browser_w`` becomes fd 4.
|
|
86
|
+
Returns the child pid. macOS and Linux only. The platform test is
|
|
87
|
+
inline so the type checker can see ``os.posix_spawn`` on Unix.
|
|
88
|
+
"""
|
|
89
|
+
if sys.platform == "win32":
|
|
90
|
+
raise ExportError("video export needs macOS or Linux")
|
|
91
|
+
actions = [
|
|
92
|
+
(os.POSIX_SPAWN_DUP2, to_browser_r, 3),
|
|
93
|
+
(os.POSIX_SPAWN_DUP2, from_browser_w, 4),
|
|
94
|
+
# Browser chatter is not the user's business.
|
|
95
|
+
(os.POSIX_SPAWN_OPEN, 1, os.devnull, os.O_WRONLY, 0),
|
|
96
|
+
(os.POSIX_SPAWN_OPEN, 2, os.devnull, os.O_WRONLY, 0),
|
|
97
|
+
]
|
|
98
|
+
return os.posix_spawn(executable, args, os.environ, file_actions=actions)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Browser:
|
|
102
|
+
"""A headless browser with one page, driven over the DevTools pipe."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, executable: str, width: int, height: int) -> None:
|
|
105
|
+
_require_posix()
|
|
106
|
+
self._profile = tempfile.mkdtemp(prefix="concurim-browser-")
|
|
107
|
+
self._next_id = 0
|
|
108
|
+
self._buffer = b""
|
|
109
|
+
to_browser_r, self._to_browser = os.pipe()
|
|
110
|
+
self._from_browser, from_browser_w = os.pipe()
|
|
111
|
+
args = [
|
|
112
|
+
executable,
|
|
113
|
+
"--headless=new",
|
|
114
|
+
"--remote-debugging-pipe",
|
|
115
|
+
f"--user-data-dir={self._profile}",
|
|
116
|
+
"--no-first-run",
|
|
117
|
+
"--no-default-browser-check",
|
|
118
|
+
"--hide-scrollbars",
|
|
119
|
+
"--force-device-scale-factor=1",
|
|
120
|
+
"--font-render-hinting=none",
|
|
121
|
+
"--disable-background-timer-throttling",
|
|
122
|
+
"--mute-audio",
|
|
123
|
+
f"--window-size={width},{height}",
|
|
124
|
+
"about:blank",
|
|
125
|
+
]
|
|
126
|
+
self._pid = _spawn_browser(
|
|
127
|
+
executable, args, to_browser_r, from_browser_w
|
|
128
|
+
)
|
|
129
|
+
os.close(to_browser_r)
|
|
130
|
+
os.close(from_browser_w)
|
|
131
|
+
self._session = self._open_page(width, height)
|
|
132
|
+
|
|
133
|
+
def _open_page(self, width: int, height: int) -> str:
|
|
134
|
+
target = self.call("Target.createTarget", {"url": "about:blank"})
|
|
135
|
+
attached = self.call(
|
|
136
|
+
"Target.attachToTarget",
|
|
137
|
+
{"targetId": target["targetId"], "flatten": True},
|
|
138
|
+
)
|
|
139
|
+
session = attached["sessionId"]
|
|
140
|
+
self.call(
|
|
141
|
+
"Emulation.setDeviceMetricsOverride",
|
|
142
|
+
{
|
|
143
|
+
"width": width,
|
|
144
|
+
"height": height,
|
|
145
|
+
"deviceScaleFactor": 1,
|
|
146
|
+
"mobile": False,
|
|
147
|
+
},
|
|
148
|
+
session,
|
|
149
|
+
)
|
|
150
|
+
self.call("Page.enable", session=session)
|
|
151
|
+
return session
|
|
152
|
+
|
|
153
|
+
def call(
|
|
154
|
+
self,
|
|
155
|
+
method: str,
|
|
156
|
+
params: dict[str, Any] | None = None,
|
|
157
|
+
session: str | None = None,
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
|
+
"""Send one command and return its result (events are skipped)."""
|
|
160
|
+
self._next_id += 1
|
|
161
|
+
message: dict[str, Any] = {
|
|
162
|
+
"id": self._next_id,
|
|
163
|
+
"method": method,
|
|
164
|
+
"params": params or {},
|
|
165
|
+
}
|
|
166
|
+
if session:
|
|
167
|
+
message["sessionId"] = session
|
|
168
|
+
os.write(self._to_browser, json.dumps(message).encode() + b"\0")
|
|
169
|
+
while True:
|
|
170
|
+
reply = self._read()
|
|
171
|
+
if reply.get("id") != self._next_id:
|
|
172
|
+
continue
|
|
173
|
+
if "error" in reply:
|
|
174
|
+
raise ExportError(f"{method}: {reply['error']}")
|
|
175
|
+
return reply.get("result", {})
|
|
176
|
+
|
|
177
|
+
def _read(self) -> dict[str, Any]:
|
|
178
|
+
while b"\0" not in self._buffer:
|
|
179
|
+
ready, _, _ = select.select(
|
|
180
|
+
[self._from_browser], [], [], _TIMEOUT_S
|
|
181
|
+
)
|
|
182
|
+
chunk = os.read(self._from_browser, 1 << 16) if ready else b""
|
|
183
|
+
if not chunk:
|
|
184
|
+
raise ExportError("the browser stopped responding")
|
|
185
|
+
self._buffer += chunk
|
|
186
|
+
raw, self._buffer = self._buffer.split(b"\0", 1)
|
|
187
|
+
return json.loads(raw)
|
|
188
|
+
|
|
189
|
+
def page(
|
|
190
|
+
self, method: str, params: dict[str, Any] | None = None
|
|
191
|
+
) -> dict[str, Any]:
|
|
192
|
+
"""Send a command to the page."""
|
|
193
|
+
return self.call(method, params, self._session)
|
|
194
|
+
|
|
195
|
+
def evaluate(self, expression: str) -> Any:
|
|
196
|
+
"""Run JavaScript in the page (awaiting promises); return the value."""
|
|
197
|
+
out = self.page(
|
|
198
|
+
"Runtime.evaluate",
|
|
199
|
+
{
|
|
200
|
+
"expression": expression,
|
|
201
|
+
"awaitPromise": True,
|
|
202
|
+
"returnByValue": True,
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
if "exceptionDetails" in out:
|
|
206
|
+
raise ExportError(f"page error: {out['exceptionDetails']}")
|
|
207
|
+
return out["result"].get("value")
|
|
208
|
+
|
|
209
|
+
def screenshot(self) -> bytes:
|
|
210
|
+
"""The visible page as PNG bytes."""
|
|
211
|
+
out = self.page("Page.captureScreenshot", {"format": "png"})
|
|
212
|
+
return base64.b64decode(out["data"])
|
|
213
|
+
|
|
214
|
+
def close(self) -> None:
|
|
215
|
+
"""Stop the browser and delete its temporary profile."""
|
|
216
|
+
for fd in (self._to_browser, self._from_browser):
|
|
217
|
+
with contextlib.suppress(OSError):
|
|
218
|
+
os.close(fd)
|
|
219
|
+
try:
|
|
220
|
+
os.kill(self._pid, signal.SIGTERM)
|
|
221
|
+
os.waitpid(self._pid, 0)
|
|
222
|
+
except (ProcessLookupError, ChildProcessError):
|
|
223
|
+
pass
|
|
224
|
+
shutil.rmtree(self._profile, ignore_errors=True)
|