detangle 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.
- detangle/__init__.py +118 -0
- detangle/__main__.py +157 -0
- detangle/_api.py +144 -0
- detangle/_choices.py +121 -0
- detangle/_config.py +111 -0
- detangle/_context.py +14 -0
- detangle/_database.py +59 -0
- detangle/_deadlock.py +226 -0
- detangle/_frames.py +185 -0
- detangle/_html.py +158 -0
- detangle/_instrument.py +87 -0
- detangle/_loop.py +1044 -0
- detangle/_runner.py +233 -0
- detangle/_settings.py +87 -0
- detangle/_shrink.py +119 -0
- detangle/_timepatch.py +52 -0
- detangle/_trace.py +308 -0
- detangle/_version.py +1 -0
- detangle/errors.py +113 -0
- detangle/explore.py +558 -0
- detangle/lin.py +619 -0
- detangle/net.py +1014 -0
- detangle/py.typed +0 -0
- detangle/pytest_plugin.py +115 -0
- detangle/strategies.py +371 -0
- detangle-0.1.0.dist-info/METADATA +376 -0
- detangle-0.1.0.dist-info/RECORD +30 -0
- detangle-0.1.0.dist-info/WHEEL +4 -0
- detangle-0.1.0.dist-info/entry_points.txt +5 -0
- detangle-0.1.0.dist-info/licenses/LICENSE +21 -0
detangle/__init__.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""detangle -- deterministic simulation testing for Python asyncio.
|
|
2
|
+
|
|
3
|
+
Run your async code under thousands of different (but perfectly
|
|
4
|
+
reproducible) schedules, network conditions and fault patterns; get the
|
|
5
|
+
race conditions, deadlocks and cancellation bugs back as minimal, replayable
|
|
6
|
+
counterexamples.
|
|
7
|
+
|
|
8
|
+
Quick start::
|
|
9
|
+
|
|
10
|
+
import asyncio, detangle
|
|
11
|
+
|
|
12
|
+
@detangle.test
|
|
13
|
+
async def test_counter():
|
|
14
|
+
counter = {"value": 0}
|
|
15
|
+
|
|
16
|
+
async def incr():
|
|
17
|
+
current = counter["value"]
|
|
18
|
+
await asyncio.sleep(0) # e.g. a database round-trip
|
|
19
|
+
counter["value"] = current + 1
|
|
20
|
+
|
|
21
|
+
await asyncio.gather(incr(), incr())
|
|
22
|
+
assert counter["value"] == 2 # fails: lost update found & shrunk
|
|
23
|
+
|
|
24
|
+
See https://github.com/louistarwars/Detangle for the documentation.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from . import lin, net, strategies
|
|
30
|
+
from ._api import (
|
|
31
|
+
choice,
|
|
32
|
+
flip,
|
|
33
|
+
in_simulation,
|
|
34
|
+
inject_cancellation,
|
|
35
|
+
invariant,
|
|
36
|
+
maybe_timeout,
|
|
37
|
+
network,
|
|
38
|
+
note,
|
|
39
|
+
now,
|
|
40
|
+
randint,
|
|
41
|
+
shuffle,
|
|
42
|
+
spawn,
|
|
43
|
+
uniform,
|
|
44
|
+
)
|
|
45
|
+
from ._choices import Decision, decode_token, encode_token
|
|
46
|
+
from ._config import NetConfig, SimConfig
|
|
47
|
+
from ._loop import SimLoop, current_loop
|
|
48
|
+
from ._runner import Failure, RunResult, execute
|
|
49
|
+
from ._version import __version__
|
|
50
|
+
from .errors import (
|
|
51
|
+
BugFound,
|
|
52
|
+
DeadlockError,
|
|
53
|
+
DetangleError,
|
|
54
|
+
InjectedTimeout,
|
|
55
|
+
InvariantViolation,
|
|
56
|
+
NotLinearizable,
|
|
57
|
+
RealIOError,
|
|
58
|
+
SimulationError,
|
|
59
|
+
StepLimitExceeded,
|
|
60
|
+
TimeLimitExceeded,
|
|
61
|
+
)
|
|
62
|
+
from .explore import BugReport, ExploreStats, explore, replay, run, test
|
|
63
|
+
from .lin import History
|
|
64
|
+
from .strategies import DFS, FIFO, PCT, Portfolio, RandomWalk, Replay, Strategy
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"DFS",
|
|
68
|
+
"FIFO",
|
|
69
|
+
"PCT",
|
|
70
|
+
"BugFound",
|
|
71
|
+
"BugReport",
|
|
72
|
+
"DeadlockError",
|
|
73
|
+
"Decision",
|
|
74
|
+
"DetangleError",
|
|
75
|
+
"ExploreStats",
|
|
76
|
+
"Failure",
|
|
77
|
+
"History",
|
|
78
|
+
"InjectedTimeout",
|
|
79
|
+
"InvariantViolation",
|
|
80
|
+
"NetConfig",
|
|
81
|
+
"NotLinearizable",
|
|
82
|
+
"Portfolio",
|
|
83
|
+
"RandomWalk",
|
|
84
|
+
"RealIOError",
|
|
85
|
+
"Replay",
|
|
86
|
+
"RunResult",
|
|
87
|
+
"SimConfig",
|
|
88
|
+
"SimLoop",
|
|
89
|
+
"SimulationError",
|
|
90
|
+
"StepLimitExceeded",
|
|
91
|
+
"Strategy",
|
|
92
|
+
"TimeLimitExceeded",
|
|
93
|
+
"__version__",
|
|
94
|
+
"choice",
|
|
95
|
+
"current_loop",
|
|
96
|
+
"decode_token",
|
|
97
|
+
"encode_token",
|
|
98
|
+
"execute",
|
|
99
|
+
"explore",
|
|
100
|
+
"flip",
|
|
101
|
+
"in_simulation",
|
|
102
|
+
"inject_cancellation",
|
|
103
|
+
"invariant",
|
|
104
|
+
"lin",
|
|
105
|
+
"maybe_timeout",
|
|
106
|
+
"net",
|
|
107
|
+
"network",
|
|
108
|
+
"note",
|
|
109
|
+
"now",
|
|
110
|
+
"randint",
|
|
111
|
+
"replay",
|
|
112
|
+
"run",
|
|
113
|
+
"shuffle",
|
|
114
|
+
"spawn",
|
|
115
|
+
"strategies",
|
|
116
|
+
"test",
|
|
117
|
+
"uniform",
|
|
118
|
+
]
|
detangle/__main__.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
detangle explore tests/test_bank.py:test_transfer --runs 2000
|
|
6
|
+
detangle replay dt1-eJxj... tests/test_bank.py:test_transfer --html report.html
|
|
7
|
+
detangle decode dt1-eJxj...
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import importlib
|
|
14
|
+
import importlib.util
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ._choices import decode_token
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load(target: str) -> Any:
|
|
26
|
+
if ":" not in target:
|
|
27
|
+
raise SystemExit(
|
|
28
|
+
f"error: target must look like 'module:function' or 'path.py:function', got {target!r}"
|
|
29
|
+
)
|
|
30
|
+
module_part, _, attr = target.rpartition(":")
|
|
31
|
+
if module_part.endswith(".py") or os.sep in module_part or "/" in module_part:
|
|
32
|
+
path = Path(module_part).resolve()
|
|
33
|
+
if not path.exists():
|
|
34
|
+
raise SystemExit(f"error: no such file: {module_part}")
|
|
35
|
+
sys.path.insert(0, str(path.parent))
|
|
36
|
+
name = path.stem
|
|
37
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
38
|
+
if spec is None or spec.loader is None:
|
|
39
|
+
raise SystemExit(f"error: cannot import {module_part}")
|
|
40
|
+
module = importlib.util.module_from_spec(spec)
|
|
41
|
+
sys.modules[name] = module
|
|
42
|
+
spec.loader.exec_module(module)
|
|
43
|
+
else:
|
|
44
|
+
sys.path.insert(0, os.getcwd())
|
|
45
|
+
module = importlib.import_module(module_part)
|
|
46
|
+
obj: Any = module
|
|
47
|
+
for part in attr.split("."):
|
|
48
|
+
obj = getattr(obj, part)
|
|
49
|
+
return getattr(obj, "_detangle_inner", obj)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _cmd_explore(args: argparse.Namespace) -> int:
|
|
53
|
+
from .errors import BugFound
|
|
54
|
+
from .explore import explore
|
|
55
|
+
|
|
56
|
+
fn = _load(args.target)
|
|
57
|
+
try:
|
|
58
|
+
stats = explore(
|
|
59
|
+
fn,
|
|
60
|
+
runs=args.runs,
|
|
61
|
+
strategy=args.strategy,
|
|
62
|
+
seed=args.seed,
|
|
63
|
+
max_duration=args.max_duration,
|
|
64
|
+
report_dir=args.report_dir,
|
|
65
|
+
database=False if args.no_db else None,
|
|
66
|
+
)
|
|
67
|
+
except BugFound as exc:
|
|
68
|
+
print(exc.report.render(trace_limit=args.trace_limit))
|
|
69
|
+
return 1
|
|
70
|
+
print(stats.summary())
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _cmd_replay(args: argparse.Namespace) -> int:
|
|
75
|
+
from .explore import BugReport, _count_non_seed, _name_of, replay
|
|
76
|
+
|
|
77
|
+
fn = _load(args.target)
|
|
78
|
+
result = replay(fn, args.token, raise_on_failure=False)
|
|
79
|
+
if result.failure is None:
|
|
80
|
+
print(
|
|
81
|
+
f"detangle: replaying {args.token}: no failure ({result.steps} steps, "
|
|
82
|
+
f"virtual time {result.virtual_time:.6f}s)"
|
|
83
|
+
)
|
|
84
|
+
if result.trace is not None and args.trace:
|
|
85
|
+
print(result.trace.render(limit=args.trace_limit))
|
|
86
|
+
return 0
|
|
87
|
+
report = BugReport(
|
|
88
|
+
name=_name_of(fn),
|
|
89
|
+
failure=result.failure,
|
|
90
|
+
token=args.token,
|
|
91
|
+
values=decode_token(args.token),
|
|
92
|
+
runs=1,
|
|
93
|
+
strategy="replay",
|
|
94
|
+
seed=None,
|
|
95
|
+
trace=result.trace,
|
|
96
|
+
deviations=_count_non_seed(result),
|
|
97
|
+
secondary=result.secondary,
|
|
98
|
+
source="replay",
|
|
99
|
+
)
|
|
100
|
+
if args.html:
|
|
101
|
+
from ._html import render_html
|
|
102
|
+
|
|
103
|
+
Path(args.html).write_text(render_html(report), encoding="utf-8")
|
|
104
|
+
report.html_report = args.html
|
|
105
|
+
if args.json:
|
|
106
|
+
print(json.dumps(report.to_dict(), indent=2, default=str))
|
|
107
|
+
else:
|
|
108
|
+
print(report.render(trace_limit=args.trace_limit))
|
|
109
|
+
return 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cmd_decode(args: argparse.Namespace) -> int:
|
|
113
|
+
values = decode_token(args.token)
|
|
114
|
+
nonzero = [(i, v) for i, v in enumerate(values) if v]
|
|
115
|
+
print(f"{len(values)} decisions, {len(nonzero)} non-default:")
|
|
116
|
+
for i, v in nonzero:
|
|
117
|
+
print(f" decision #{i}: {v}")
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main(argv: list[str] | None = None) -> int:
|
|
122
|
+
parser = argparse.ArgumentParser(
|
|
123
|
+
prog="detangle", description="Deterministic simulation testing for Python asyncio."
|
|
124
|
+
)
|
|
125
|
+
parser.add_argument("--version", action="version", version=f"detangle {__version__}")
|
|
126
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
127
|
+
|
|
128
|
+
p = sub.add_parser("explore", help="explore the schedules of an async function")
|
|
129
|
+
p.add_argument("target", help="module:function or path.py:function")
|
|
130
|
+
p.add_argument("--runs", type=int, default=None)
|
|
131
|
+
p.add_argument("--strategy", default=None)
|
|
132
|
+
p.add_argument("--seed", type=lambda s: int(s, 0), default=None)
|
|
133
|
+
p.add_argument("--max-duration", type=float, default=None)
|
|
134
|
+
p.add_argument("--report-dir", default=None)
|
|
135
|
+
p.add_argument("--no-db", action="store_true")
|
|
136
|
+
p.add_argument("--trace-limit", type=int, default=80)
|
|
137
|
+
p.set_defaults(func=_cmd_explore)
|
|
138
|
+
|
|
139
|
+
p = sub.add_parser("replay", help="replay a token printed by a failing run")
|
|
140
|
+
p.add_argument("token")
|
|
141
|
+
p.add_argument("target", help="module:function or path.py:function")
|
|
142
|
+
p.add_argument("--html", default=None, help="write an interactive HTML report")
|
|
143
|
+
p.add_argument("--json", action="store_true", help="print the report as JSON")
|
|
144
|
+
p.add_argument("--trace", action="store_true", help="print the trace even if the run passes")
|
|
145
|
+
p.add_argument("--trace-limit", type=int, default=0, help="max trace lines (0 = all)")
|
|
146
|
+
p.set_defaults(func=_cmd_replay)
|
|
147
|
+
|
|
148
|
+
p = sub.add_parser("decode", help="show the decisions inside a token")
|
|
149
|
+
p.add_argument("token")
|
|
150
|
+
p.set_defaults(func=_cmd_decode)
|
|
151
|
+
|
|
152
|
+
args = parser.parse_args(argv)
|
|
153
|
+
return int(args.func(args))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
raise SystemExit(main())
|
detangle/_api.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Helpers meant to be called from code running *inside* a simulation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Awaitable, Callable, Coroutine, MutableSequence, Sequence
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
from ._choices import DATA
|
|
10
|
+
from ._context import current_host
|
|
11
|
+
from ._frames import caller_location
|
|
12
|
+
from ._loop import SimLoop, current_loop
|
|
13
|
+
from .errors import InjectedTimeout
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"choice",
|
|
17
|
+
"flip",
|
|
18
|
+
"in_simulation",
|
|
19
|
+
"inject_cancellation",
|
|
20
|
+
"invariant",
|
|
21
|
+
"maybe_timeout",
|
|
22
|
+
"network",
|
|
23
|
+
"note",
|
|
24
|
+
"now",
|
|
25
|
+
"randint",
|
|
26
|
+
"shuffle",
|
|
27
|
+
"spawn",
|
|
28
|
+
"uniform",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
_T = TypeVar("_T")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def in_simulation() -> bool:
|
|
35
|
+
"""True when called from code running inside a detangle simulation."""
|
|
36
|
+
return isinstance(asyncio.events._get_running_loop(), SimLoop)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def now() -> float:
|
|
40
|
+
"""Current virtual time, in seconds (same as ``loop.time()``)."""
|
|
41
|
+
return current_loop().time()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def choice(options: Sequence[_T]) -> _T:
|
|
45
|
+
"""Pick one of *options*. Explored by the strategy; shrinks to the first one."""
|
|
46
|
+
if not options:
|
|
47
|
+
raise IndexError("cannot choose from an empty sequence")
|
|
48
|
+
return options[current_loop().choose(len(options), DATA)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def randint(a: int, b: int) -> int:
|
|
52
|
+
"""An integer in ``[a, b]``. Explored by the strategy; shrinks towards *a*."""
|
|
53
|
+
if b < a:
|
|
54
|
+
raise ValueError(f"empty range for randint({a}, {b})")
|
|
55
|
+
return a + current_loop().choose(b - a + 1, DATA)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def uniform(a: float, b: float, steps: int = 64) -> float:
|
|
59
|
+
"""A float in ``[a, b]`` (one of *steps* evenly spaced values; shrinks towards *a*)."""
|
|
60
|
+
if steps < 2:
|
|
61
|
+
return a
|
|
62
|
+
k = current_loop().choose(steps, DATA)
|
|
63
|
+
return a + (b - a) * k / (steps - 1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def flip(p: float = 0.5) -> bool:
|
|
67
|
+
"""True with probability *p*. Shrinks towards False."""
|
|
68
|
+
return current_loop().flip(p, DATA)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def shuffle(items: MutableSequence[Any]) -> None:
|
|
72
|
+
"""Shuffle *items* in place. The default (all zeros) is the identity."""
|
|
73
|
+
loop = current_loop()
|
|
74
|
+
for i in range(len(items) - 1, 0, -1):
|
|
75
|
+
j = i - loop.choose(i + 1, DATA)
|
|
76
|
+
items[i], items[j] = items[j], items[i]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def invariant(check: Callable[[], object], name: str | None = None) -> Callable[[], object]:
|
|
80
|
+
"""Check ``check()`` after *every* step of the simulation.
|
|
81
|
+
|
|
82
|
+
The check fails if it raises ``AssertionError`` (or any exception) or
|
|
83
|
+
returns ``False``. Returns *check* so it can be used as a decorator.
|
|
84
|
+
"""
|
|
85
|
+
current_loop().add_invariant(check, name)
|
|
86
|
+
return check
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def note(message: str) -> None:
|
|
90
|
+
"""Add a line to the trace shown when a bug is found (no-op otherwise)."""
|
|
91
|
+
loop = current_loop()
|
|
92
|
+
if loop.tracer is not None:
|
|
93
|
+
loop.tracer.on_note(asyncio.current_task(), message, caller_location(1))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def inject_cancellation(task: asyncio.Task[Any], points: int = 8) -> None:
|
|
97
|
+
"""Allow the strategy to cancel *task* at one of its next *points* awaits.
|
|
98
|
+
|
|
99
|
+
Models timeouts and client disconnects hitting at the worst moment.
|
|
100
|
+
"""
|
|
101
|
+
current_loop().mark_interruptible(task, points)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def maybe_timeout(aw: Awaitable[_T], points: int = 8) -> _T:
|
|
105
|
+
"""Await *aw*, but let the strategy "time it out" at any of its awaits.
|
|
106
|
+
|
|
107
|
+
Equivalent to ``asyncio.wait_for(aw, timeout=<adversarial>)``: returns the
|
|
108
|
+
result, or cancels the operation at one of its first *points* suspension
|
|
109
|
+
points and raises :class:`TimeoutError`. Use it to find code that is not
|
|
110
|
+
cancellation-safe (leaked locks, connections, half-applied updates...).
|
|
111
|
+
"""
|
|
112
|
+
loop = current_loop()
|
|
113
|
+
task = asyncio.ensure_future(aw)
|
|
114
|
+
loop.mark_interruptible(task, points)
|
|
115
|
+
try:
|
|
116
|
+
return await task
|
|
117
|
+
except asyncio.CancelledError:
|
|
118
|
+
if task in loop.injected and task.cancelled():
|
|
119
|
+
current = asyncio.current_task()
|
|
120
|
+
if current is None or not getattr(current, "cancelling", lambda: 0)():
|
|
121
|
+
raise InjectedTimeout(
|
|
122
|
+
f"detangle: injected timeout (cancelled at {loop.injected[task]})"
|
|
123
|
+
) from None
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def spawn(
|
|
128
|
+
coro: Coroutine[Any, Any, _T], *, host: str | None = None, name: str | None = None
|
|
129
|
+
) -> asyncio.Task[_T]:
|
|
130
|
+
"""Create a task, optionally running on simulated *host* (see :mod:`detangle.net`)."""
|
|
131
|
+
loop = current_loop()
|
|
132
|
+
if host is None:
|
|
133
|
+
return loop.create_task(coro, name=name)
|
|
134
|
+
loop.net.ensure_host(host)
|
|
135
|
+
token = current_host.set(host)
|
|
136
|
+
try:
|
|
137
|
+
return loop.create_task(coro, name=name)
|
|
138
|
+
finally:
|
|
139
|
+
current_host.reset(token)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def network() -> Any:
|
|
143
|
+
"""The simulated network (:class:`detangle.net.SimNetwork`) of this run."""
|
|
144
|
+
return current_loop().net
|
detangle/_choices.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Decisions, choice sequences and replay tokens.
|
|
2
|
+
|
|
3
|
+
Every source of nondeterminism in a simulation -- which ready task runs next,
|
|
4
|
+
how late a timer fires, how long a network packet takes, whether a fault is
|
|
5
|
+
injected -- is funnelled through a single primitive: *pick an integer in
|
|
6
|
+
``range(n)``*. The value ``0`` always means "what asyncio would do by
|
|
7
|
+
default" (run the oldest ready callback, fire the timer on time, deliver the
|
|
8
|
+
packet with minimum latency, inject no fault).
|
|
9
|
+
|
|
10
|
+
A run is therefore fully described by the list of integers it picked. That
|
|
11
|
+
list can be replayed, mutated, shrunk and serialised into a short *token*
|
|
12
|
+
which reproduces the run bit-for-bit.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import zlib
|
|
19
|
+
from collections.abc import Iterable, Sequence
|
|
20
|
+
from typing import NamedTuple
|
|
21
|
+
|
|
22
|
+
__all__ = ["Decision", "decode_token", "encode_token"]
|
|
23
|
+
|
|
24
|
+
TOKEN_PREFIX = "dt1-"
|
|
25
|
+
|
|
26
|
+
#: Decision kinds.
|
|
27
|
+
SCHED = "sched" # which ready lane runs next
|
|
28
|
+
TIMER = "timer" # timer jitter bucket
|
|
29
|
+
NET = "net" # network latency bucket / fragmentation
|
|
30
|
+
FAULT = "fault" # injected failures (drops, duplicates, cancellations)
|
|
31
|
+
DATA = "data" # user-level nondeterministic values (detangle.choice & co)
|
|
32
|
+
SEED = "seed" # seed for the global ``random`` module
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Decision(NamedTuple):
|
|
36
|
+
"""One nondeterministic choice made during a run."""
|
|
37
|
+
|
|
38
|
+
kind: str
|
|
39
|
+
n: int
|
|
40
|
+
value: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _write_varint(out: bytearray, value: int) -> None:
|
|
44
|
+
if value < 0:
|
|
45
|
+
raise ValueError("choice values must be non-negative")
|
|
46
|
+
while True:
|
|
47
|
+
byte = value & 0x7F
|
|
48
|
+
value >>= 7
|
|
49
|
+
if value:
|
|
50
|
+
out.append(byte | 0x80)
|
|
51
|
+
else:
|
|
52
|
+
out.append(byte)
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_varints(data: bytes) -> list[int]:
|
|
57
|
+
values: list[int] = []
|
|
58
|
+
shift = 0
|
|
59
|
+
current = 0
|
|
60
|
+
for byte in data:
|
|
61
|
+
current |= (byte & 0x7F) << shift
|
|
62
|
+
if byte & 0x80:
|
|
63
|
+
shift += 7
|
|
64
|
+
if shift > 70:
|
|
65
|
+
raise ValueError("corrupt detangle token (varint too long)")
|
|
66
|
+
else:
|
|
67
|
+
values.append(current)
|
|
68
|
+
current = 0
|
|
69
|
+
shift = 0
|
|
70
|
+
if shift:
|
|
71
|
+
raise ValueError("corrupt detangle token (truncated varint)")
|
|
72
|
+
return values
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def strip_trailing_zeros(values: Sequence[int]) -> list[int]:
|
|
76
|
+
"""Trailing zeros are implicit: replaying past the end picks 0."""
|
|
77
|
+
end = len(values)
|
|
78
|
+
while end and values[end - 1] == 0:
|
|
79
|
+
end -= 1
|
|
80
|
+
return list(values[:end])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def encode_token(values: Iterable[int]) -> str:
|
|
84
|
+
"""Serialise a choice sequence into a compact, URL/shell-safe token."""
|
|
85
|
+
raw = bytearray()
|
|
86
|
+
for v in strip_trailing_zeros(list(values)):
|
|
87
|
+
_write_varint(raw, v)
|
|
88
|
+
compressed = zlib.compress(bytes(raw), 9)
|
|
89
|
+
payload = b"z" + compressed if len(compressed) < len(raw) else b"r" + bytes(raw)
|
|
90
|
+
text = base64.urlsafe_b64encode(payload).rstrip(b"=").decode("ascii")
|
|
91
|
+
return TOKEN_PREFIX + text
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def decode_token(token: str) -> list[int]:
|
|
95
|
+
"""Inverse of :func:`encode_token`."""
|
|
96
|
+
token = token.strip()
|
|
97
|
+
if not token.startswith(TOKEN_PREFIX):
|
|
98
|
+
raise ValueError(f"not a detangle token (expected prefix {TOKEN_PREFIX!r}): {token!r}")
|
|
99
|
+
text = token[len(TOKEN_PREFIX) :]
|
|
100
|
+
padded = text + "=" * (-len(text) % 4)
|
|
101
|
+
try:
|
|
102
|
+
payload = base64.urlsafe_b64decode(padded.encode("ascii"))
|
|
103
|
+
except ValueError as exc: # binascii.Error is a ValueError
|
|
104
|
+
raise ValueError(f"corrupt detangle token: {exc}") from None
|
|
105
|
+
if not payload:
|
|
106
|
+
raise ValueError("corrupt detangle token (empty payload)")
|
|
107
|
+
tag, body = payload[:1], payload[1:]
|
|
108
|
+
if tag == b"z":
|
|
109
|
+
try:
|
|
110
|
+
body = zlib.decompress(body)
|
|
111
|
+
except zlib.error as exc:
|
|
112
|
+
raise ValueError(f"corrupt detangle token: {exc}") from None
|
|
113
|
+
elif tag != b"r":
|
|
114
|
+
raise ValueError("corrupt detangle token (unknown encoding)")
|
|
115
|
+
return _read_varints(body)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def complexity(values: Sequence[int]) -> tuple[int, int, int]:
|
|
119
|
+
"""Ordering used by the shrinker: fewer deviations, then smaller, then shorter."""
|
|
120
|
+
nonzero = sum(1 for v in values if v)
|
|
121
|
+
return (nonzero, sum(min(v, 1 << 20) for v in values), len(strip_trailing_zeros(values)))
|
detangle/_config.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Simulation configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
__all__ = ["NetConfig", "SimConfig"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class NetConfig:
|
|
14
|
+
"""Behaviour of the simulated network.
|
|
15
|
+
|
|
16
|
+
All durations are in virtual seconds. Latencies are drawn from
|
|
17
|
+
``latency_steps`` evenly spaced values between ``latency[0]`` and
|
|
18
|
+
``latency[1]``; the minimum is the default (choice 0).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
latency: tuple[float, float] = (0.0005, 0.005)
|
|
22
|
+
latency_steps: int = 4
|
|
23
|
+
#: Probability that a datagram / mailbox message is lost.
|
|
24
|
+
drop: float = 0.0
|
|
25
|
+
#: Probability that a datagram / mailbox message is delivered twice.
|
|
26
|
+
duplicate: float = 0.0
|
|
27
|
+
#: Probability that a TCP write is split into several reads on the peer.
|
|
28
|
+
fragment: float = 0.0
|
|
29
|
+
#: Maximum number of pieces a fragmented write is split into.
|
|
30
|
+
max_fragments: int = 3
|
|
31
|
+
#: Time before a connection attempt to an unreachable host fails.
|
|
32
|
+
connect_timeout: float = 10.0
|
|
33
|
+
#: Time a TCP connection survives a partition before being reset.
|
|
34
|
+
tcp_timeout: float = 30.0
|
|
35
|
+
#: Retransmission interval while a link is partitioned.
|
|
36
|
+
retransmit_interval: float = 0.2
|
|
37
|
+
#: Deep-copy mailbox messages (simulates serialisation, catches aliasing).
|
|
38
|
+
copy_messages: bool = True
|
|
39
|
+
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
lo, hi = self.latency
|
|
42
|
+
if lo < 0 or hi < lo:
|
|
43
|
+
raise ValueError(f"invalid latency range {self.latency!r}")
|
|
44
|
+
for name in ("drop", "duplicate", "fragment"):
|
|
45
|
+
value = getattr(self, name)
|
|
46
|
+
if not 0.0 <= value <= 1.0:
|
|
47
|
+
raise ValueError(f"{name} must be a probability, got {value!r}")
|
|
48
|
+
if self.latency_steps < 1 or self.max_fragments < 1:
|
|
49
|
+
raise ValueError("latency_steps and max_fragments must be >= 1")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class SimConfig:
|
|
54
|
+
"""Knobs controlling what a simulation explores and what counts as a bug."""
|
|
55
|
+
|
|
56
|
+
#: Explore reorderings of ready tasks. ``False`` keeps asyncio's strict
|
|
57
|
+
#: FIFO order and only explores time/network/fault nondeterminism.
|
|
58
|
+
reorder: bool = True
|
|
59
|
+
#: Maximum extra delay (virtual seconds) a timer may fire late.
|
|
60
|
+
timer_jitter: float = 0.0
|
|
61
|
+
#: Number of distinct jitter values explored between 0 and ``timer_jitter``.
|
|
62
|
+
jitter_steps: int = 4
|
|
63
|
+
#: Virtual time consumed by every step (models CPU time; 0 = infinitely fast).
|
|
64
|
+
step_cost: float = 0.0
|
|
65
|
+
#: Abort the run (as a failure) after this many steps.
|
|
66
|
+
max_steps: int = 200_000
|
|
67
|
+
#: Abort the run (as a failure) when virtual time exceeds this value.
|
|
68
|
+
max_time: float | None = None
|
|
69
|
+
#: Exceptions in background tasks that nobody retrieved are failures.
|
|
70
|
+
fail_on_unobserved: bool = True
|
|
71
|
+
#: Exceptions escaping plain callbacks (``loop.call_soon`` & co) are failures.
|
|
72
|
+
fail_on_callback_error: bool = True
|
|
73
|
+
#: Tasks still pending when the main coroutine returns are failures.
|
|
74
|
+
check_leaks: bool = False
|
|
75
|
+
#: Seed the global :mod:`random` module from the schedule (restored after).
|
|
76
|
+
seed_random: bool = True
|
|
77
|
+
#: Patch ``time.time``/``time.monotonic``/``time.perf_counter`` to virtual time.
|
|
78
|
+
patch_time: bool = False
|
|
79
|
+
#: Network simulation parameters.
|
|
80
|
+
net: NetConfig = field(default_factory=NetConfig)
|
|
81
|
+
|
|
82
|
+
def __post_init__(self) -> None:
|
|
83
|
+
if self.timer_jitter < 0 or self.step_cost < 0:
|
|
84
|
+
raise ValueError("timer_jitter and step_cost must be >= 0")
|
|
85
|
+
if self.jitter_steps < 2 and self.timer_jitter > 0:
|
|
86
|
+
raise ValueError("jitter_steps must be >= 2 when timer_jitter > 0")
|
|
87
|
+
if self.max_steps < 1:
|
|
88
|
+
raise ValueError("max_steps must be >= 1")
|
|
89
|
+
|
|
90
|
+
def replace(self, **changes: Any) -> SimConfig:
|
|
91
|
+
return dataclasses.replace(self, **changes)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_SIM_FIELDS = {f.name for f in dataclasses.fields(SimConfig)}
|
|
95
|
+
_NET_FIELDS = {f.name for f in dataclasses.fields(NetConfig)}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_config(config: SimConfig | None, options: dict[str, Any]) -> SimConfig:
|
|
99
|
+
"""Merge keyword options (``timer_jitter=...``, ``net=...``) into a config."""
|
|
100
|
+
base = config or SimConfig()
|
|
101
|
+
sim_changes: dict[str, Any] = {}
|
|
102
|
+
for key, value in options.items():
|
|
103
|
+
if key not in _SIM_FIELDS:
|
|
104
|
+
raise TypeError(f"unknown simulation option {key!r}")
|
|
105
|
+
if key == "net" and isinstance(value, dict):
|
|
106
|
+
bad = set(value) - _NET_FIELDS
|
|
107
|
+
if bad:
|
|
108
|
+
raise TypeError(f"unknown network option(s): {', '.join(sorted(bad))}")
|
|
109
|
+
value = dataclasses.replace(base.net, **value)
|
|
110
|
+
sim_changes[key] = value
|
|
111
|
+
return base.replace(**sim_changes) if sim_changes else base
|
detangle/_context.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Context variables shared by the loop and the network simulation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
|
|
7
|
+
#: Simulated host the current task runs on (``None`` = the default host).
|
|
8
|
+
current_host: ContextVar[str | None] = ContextVar("detangle_current_host", default=None)
|
|
9
|
+
|
|
10
|
+
DEFAULT_HOST = "localhost"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def host_name() -> str:
|
|
14
|
+
return current_host.get() or DEFAULT_HOST
|