simloom 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {simloom-0.2.0 → simloom-0.3.0}/CHANGELOG.md +33 -1
- {simloom-0.2.0 → simloom-0.3.0}/PKG-INFO +15 -8
- {simloom-0.2.0 → simloom-0.3.0}/README.md +13 -7
- {simloom-0.2.0 → simloom-0.3.0}/pyproject.toml +1 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/__init__.py +12 -1
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_cli.py +78 -0
- simloom-0.3.0/src/simloom/_debugger.py +173 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_errors.py +1 -1
- simloom-0.3.0/src/simloom/_linearizability.py +238 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_run.py +16 -1
- simloom-0.3.0/src/simloom/_services.py +322 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_version.py +1 -1
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_world.py +16 -0
- simloom-0.2.0/src/simloom/_services.py +0 -175
- {simloom-0.2.0 → simloom-0.3.0}/.gitignore +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/LICENSE +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/examples/README.md +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_boundary.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_buggify.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_consistency.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_context.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_eventlog.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_explore.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_fingerprint.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_hashseed.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_loop.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_monitors.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_net.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_patches.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_pytest_plugin.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_sched.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_shrink.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_soak.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_systematic.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_tape.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_testing.py +0 -0
- {simloom-0.2.0 → simloom-0.3.0}/src/simloom/py.typed +0 -0
|
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-07-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Time-travel debugging v1.** `run(..., debug=True)` (implies `causal=True`)
|
|
14
|
+
installs a `sys.monitoring` recorder over *user code*: every line executed,
|
|
15
|
+
every local-variable change (`name = repr`, address-scrubbed and truncated),
|
|
16
|
+
and every call/return with arguments and values — all keyed to the scheduler
|
|
17
|
+
step that ran them. `simloom debug LOG` answers omniscient queries over the
|
|
18
|
+
recording: `--step N` (the code that ran during a step), `--var x` (the full
|
|
19
|
+
timeline of a variable — a lost update's double stale read is one query),
|
|
20
|
+
`--fn name` (calls/returns). Stdlib/site-packages/simloom are filtered out and
|
|
21
|
+
their monitoring locations disabled after first sight; debug runs are
|
|
22
|
+
deterministic and replay exactly.
|
|
23
|
+
- **Stand-in services: sim-nats.** `world.run_service(SimNats(), host=, port=)`
|
|
24
|
+
starts an in-sim NATS broker an *unmodified* `nats-py` client speaks to —
|
|
25
|
+
publish/subscribe with subject wildcards (`*`/`>`), request/reply, and queue
|
|
26
|
+
groups. Which member of a queue group receives each message is a **tape
|
|
27
|
+
draw**, so exploration covers every delivery assignment a real broker could
|
|
28
|
+
make (measured: distinct worker assignments across seeds, exactly-once per
|
|
29
|
+
message on all of them). Wire faults (partition the broker away) surface as
|
|
30
|
+
deterministic, replayable timeouts.
|
|
31
|
+
- **Wing-Gong linearizability checking.** `world.registers` records
|
|
32
|
+
read/write/cas operations (invoke/complete pairs, so real-time precedence is
|
|
33
|
+
exact); `world.check_linearizable()` / `assert_linearizable()` run Wing &
|
|
34
|
+
Gong's algorithm with memoisation. Stricter than serializability: the order
|
|
35
|
+
must respect real time, so a *stale read* is caught even when a serial order
|
|
36
|
+
could excuse it. Pending (crashed) operations are handled the Jepsen way —
|
|
37
|
+
they may or may not have taken effect, and both worlds are explored. Keys are
|
|
38
|
+
independent registers (linearizability is local), and a violation reports the
|
|
39
|
+
deepest valid prefix plus exactly which operation cannot take effect and why.
|
|
40
|
+
|
|
10
41
|
## [0.2.0] - 2026-06-25
|
|
11
42
|
|
|
12
43
|
### Added
|
|
@@ -180,6 +211,7 @@ explorer, shrinker, and pytest plugin are all present and exercised by a
|
|
|
180
211
|
race (bpo-42130) reproduced deterministically from a seed; the modern stdlib
|
|
181
212
|
survives the identical torture. Launch-post draft in `docs/launch-post.md`.
|
|
182
213
|
|
|
183
|
-
[Unreleased]: https://github.com/mandipadk/simloom/compare/v0.
|
|
214
|
+
[Unreleased]: https://github.com/mandipadk/simloom/compare/v0.3.0...HEAD
|
|
215
|
+
[0.3.0]: https://github.com/mandipadk/simloom/compare/v0.2.0...v0.3.0
|
|
184
216
|
[0.2.0]: https://github.com/mandipadk/simloom/compare/v0.1.0...v0.2.0
|
|
185
217
|
[0.1.0]: https://github.com/mandipadk/simloom/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simloom
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Deterministic simulation testing for asyncio: explore every schedule, inject faults, assert properties, prove correctness, and replay any failure from a seed.
|
|
5
5
|
Project-URL: Homepage, https://github.com/mandipadk/simloom
|
|
6
6
|
Project-URL: Repository, https://github.com/mandipadk/simloom
|
|
@@ -30,6 +30,7 @@ Requires-Dist: ruff>=0.9; extra == 'dev'
|
|
|
30
30
|
Provides-Extra: spikes
|
|
31
31
|
Requires-Dist: aiohttp>=3.11; extra == 'spikes'
|
|
32
32
|
Requires-Dist: httpx>=0.28; extra == 'spikes'
|
|
33
|
+
Requires-Dist: nats-py>=2.9; extra == 'spikes'
|
|
33
34
|
Requires-Dist: redis>=5.0; extra == 'spikes'
|
|
34
35
|
Description-Content-Type: text/markdown
|
|
35
36
|
|
|
@@ -197,8 +198,10 @@ faults injected, replayable byte-for-byte from a seed.
|
|
|
197
198
|
(memory-BIO SSL, so aiohttp HTTPS just works).
|
|
198
199
|
- **💥 Honest crashes & disk** — `host.crash()` is a power cut: tasks stop with no
|
|
199
200
|
`finally` blocks, unsynced disk writes are lost or *torn*, peers see resets.
|
|
200
|
-
- **🧩 Stand-in services** — `world.run_service(
|
|
201
|
-
(SET/GET, WATCH/MULTI/EXEC
|
|
201
|
+
- **🧩 Stand-in services** — `world.run_service(...)` gives you an in-sim **Redis**
|
|
202
|
+
(SET/GET, WATCH/MULTI/EXEC, for unmodified redis-py) and an in-sim **NATS** broker
|
|
203
|
+
(pub/sub, wildcards, queue groups, for unmodified nats-py) — with wire faults applied,
|
|
204
|
+
and queue-group delivery choices explored from the tape.
|
|
202
205
|
|
|
203
206
|
**Break it.** Explore the schedules and faults your laptop never will.
|
|
204
207
|
|
|
@@ -218,8 +221,10 @@ faults injected, replayable byte-for-byte from a seed.
|
|
|
218
221
|
*exhaustive* delay-bounded model checking: it finds a deep interleaving bug
|
|
219
222
|
deterministically, or **passes as a bounded proof of correctness**.
|
|
220
223
|
- **🧾 Find wrong answers** — an Elle-style serializability checker
|
|
221
|
-
(`world.check_serializable`)
|
|
222
|
-
|
|
224
|
+
(`world.check_serializable`) reports the cyclic read/write dependency behind a lost
|
|
225
|
+
update, and a Wing-Gong **linearizability** checker (`world.check_linearizable`)
|
|
226
|
+
catches the stale read serializability would excuse — including may-or-may-not-have-
|
|
227
|
+
applied writes from crashed clients.
|
|
223
228
|
|
|
224
229
|
**Reproduce & debug it.**
|
|
225
230
|
|
|
@@ -227,9 +232,11 @@ faults injected, replayable byte-for-byte from a seed.
|
|
|
227
232
|
replayable artifact on disk.
|
|
228
233
|
- **🔁 Determinism guarantees** — a per-test self-check (run twice, locate the first
|
|
229
234
|
diverging event), `PYTHONHASHSEED` auto-pin, and a queryable boundary registry.
|
|
230
|
-
- **🕰 Causal trace** — record with `causal=True` and walk it: `simloom trace
|
|
231
|
-
reconstructs state and the *happens-before stack* that woke it;
|
|
232
|
-
|
|
235
|
+
- **🕰 Causal trace & time travel** — record with `causal=True` and walk it: `simloom trace
|
|
236
|
+
LOG --step N` reconstructs state and the *happens-before stack* that woke it; `simloom
|
|
237
|
+
diff A B` finds the first divergence between two universes. Record with `debug=True`
|
|
238
|
+
and query **everything**: `simloom debug LOG --var balance` prints every value a local
|
|
239
|
+
variable ever held — a lost update's double stale read is one query.
|
|
233
240
|
- **🚨 Escape detection** — touch a real socket, signal, subprocess, or (un-patched) wall
|
|
234
241
|
clock from inside the sim and you get an `EscapedSimulationError` at the exact call site
|
|
235
242
|
instead of silent nondeterminism.
|
|
@@ -162,8 +162,10 @@ faults injected, replayable byte-for-byte from a seed.
|
|
|
162
162
|
(memory-BIO SSL, so aiohttp HTTPS just works).
|
|
163
163
|
- **💥 Honest crashes & disk** — `host.crash()` is a power cut: tasks stop with no
|
|
164
164
|
`finally` blocks, unsynced disk writes are lost or *torn*, peers see resets.
|
|
165
|
-
- **🧩 Stand-in services** — `world.run_service(
|
|
166
|
-
(SET/GET, WATCH/MULTI/EXEC
|
|
165
|
+
- **🧩 Stand-in services** — `world.run_service(...)` gives you an in-sim **Redis**
|
|
166
|
+
(SET/GET, WATCH/MULTI/EXEC, for unmodified redis-py) and an in-sim **NATS** broker
|
|
167
|
+
(pub/sub, wildcards, queue groups, for unmodified nats-py) — with wire faults applied,
|
|
168
|
+
and queue-group delivery choices explored from the tape.
|
|
167
169
|
|
|
168
170
|
**Break it.** Explore the schedules and faults your laptop never will.
|
|
169
171
|
|
|
@@ -183,8 +185,10 @@ faults injected, replayable byte-for-byte from a seed.
|
|
|
183
185
|
*exhaustive* delay-bounded model checking: it finds a deep interleaving bug
|
|
184
186
|
deterministically, or **passes as a bounded proof of correctness**.
|
|
185
187
|
- **🧾 Find wrong answers** — an Elle-style serializability checker
|
|
186
|
-
(`world.check_serializable`)
|
|
187
|
-
|
|
188
|
+
(`world.check_serializable`) reports the cyclic read/write dependency behind a lost
|
|
189
|
+
update, and a Wing-Gong **linearizability** checker (`world.check_linearizable`)
|
|
190
|
+
catches the stale read serializability would excuse — including may-or-may-not-have-
|
|
191
|
+
applied writes from crashed clients.
|
|
188
192
|
|
|
189
193
|
**Reproduce & debug it.**
|
|
190
194
|
|
|
@@ -192,9 +196,11 @@ faults injected, replayable byte-for-byte from a seed.
|
|
|
192
196
|
replayable artifact on disk.
|
|
193
197
|
- **🔁 Determinism guarantees** — a per-test self-check (run twice, locate the first
|
|
194
198
|
diverging event), `PYTHONHASHSEED` auto-pin, and a queryable boundary registry.
|
|
195
|
-
- **🕰 Causal trace** — record with `causal=True` and walk it: `simloom trace
|
|
196
|
-
reconstructs state and the *happens-before stack* that woke it;
|
|
197
|
-
|
|
199
|
+
- **🕰 Causal trace & time travel** — record with `causal=True` and walk it: `simloom trace
|
|
200
|
+
LOG --step N` reconstructs state and the *happens-before stack* that woke it; `simloom
|
|
201
|
+
diff A B` finds the first divergence between two universes. Record with `debug=True`
|
|
202
|
+
and query **everything**: `simloom debug LOG --var balance` prints every value a local
|
|
203
|
+
variable ever held — a lost update's double stale read is one query.
|
|
198
204
|
- **🚨 Escape detection** — touch a real socket, signal, subprocess, or (un-patched) wall
|
|
199
205
|
clock from inside the sim and you get an `EscapedSimulationError` at the exact call site
|
|
200
206
|
instead of silent nondeterminism.
|
|
@@ -27,12 +27,18 @@ from ._eventlog import EVENT_LOG_FORMAT_VERSION, EventLog
|
|
|
27
27
|
from ._explore import Exploration, Failure, explore
|
|
28
28
|
from ._fingerprint import CorpusEntry, InterleavingCorpus, fingerprint, interleaving_edges
|
|
29
29
|
from ._hashseed import is_pinned, pin_hashseed
|
|
30
|
+
from ._linearizability import (
|
|
31
|
+
LinCall,
|
|
32
|
+
LinearizabilityResult,
|
|
33
|
+
RegisterHistory,
|
|
34
|
+
check_linearizable,
|
|
35
|
+
)
|
|
30
36
|
from ._loop import SimLoop
|
|
31
37
|
from ._monitors import always, eventually, leads_to
|
|
32
38
|
from ._net import SimDatagramTransport, SimNetwork, SimServer, SimTransport
|
|
33
39
|
from ._run import RunResult, replay, run
|
|
34
40
|
from ._sched import PCT, RandomWalk
|
|
35
|
-
from ._services import SimRedis
|
|
41
|
+
from ._services import SimNats, SimRedis
|
|
36
42
|
from ._shrink import ShrinkResult, shrink
|
|
37
43
|
from ._soak import SoakReport, soak
|
|
38
44
|
from ._systematic import SystematicResult, explore_systematic
|
|
@@ -58,8 +64,11 @@ __all__ = [
|
|
|
58
64
|
"Host",
|
|
59
65
|
"InterleavingCorpus",
|
|
60
66
|
"InvariantViolation",
|
|
67
|
+
"LinCall",
|
|
68
|
+
"LinearizabilityResult",
|
|
61
69
|
"MisalignmentPolicy",
|
|
62
70
|
"RandomWalk",
|
|
71
|
+
"RegisterHistory",
|
|
63
72
|
"RunResult",
|
|
64
73
|
"SerializabilityResult",
|
|
65
74
|
"Settings",
|
|
@@ -69,6 +78,7 @@ __all__ = [
|
|
|
69
78
|
"SimDisk",
|
|
70
79
|
"SimLivelockError",
|
|
71
80
|
"SimLoop",
|
|
81
|
+
"SimNats",
|
|
72
82
|
"SimNetwork",
|
|
73
83
|
"SimRedis",
|
|
74
84
|
"SimServer",
|
|
@@ -85,6 +95,7 @@ __all__ = [
|
|
|
85
95
|
"__version__",
|
|
86
96
|
"always",
|
|
87
97
|
"boundary",
|
|
98
|
+
"check_linearizable",
|
|
88
99
|
"check_serializable",
|
|
89
100
|
"draw",
|
|
90
101
|
"eventually",
|
|
@@ -139,6 +139,77 @@ def _cmd_diff(args: argparse.Namespace) -> int:
|
|
|
139
139
|
return 1
|
|
140
140
|
|
|
141
141
|
|
|
142
|
+
def var_timeline(events: Sequence[Event], name: str) -> list[Event]:
|
|
143
|
+
"""Every point a local variable (debug mode) or observed value changed."""
|
|
144
|
+
out: list[Event] = []
|
|
145
|
+
for event in events:
|
|
146
|
+
if event.get("kind") == "trace" and name in (event.get("changed") or {}):
|
|
147
|
+
out.append(event)
|
|
148
|
+
elif event.get("kind") == "trace" and event.get("event") == "call":
|
|
149
|
+
if name in (event.get("args") or {}):
|
|
150
|
+
out.append(event)
|
|
151
|
+
elif event.get("kind") == "observe" and event.get("name") == name:
|
|
152
|
+
out.append(event)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _format_trace(event: Event, name: str | None = None) -> str:
|
|
157
|
+
step = event.get("step")
|
|
158
|
+
prefix = f"step {step:>4}" if step is not None else "step -"
|
|
159
|
+
kind = event.get("event")
|
|
160
|
+
if kind == "call":
|
|
161
|
+
args = ", ".join(f"{k}={v}" for k, v in (event.get("args") or {}).items())
|
|
162
|
+
return f"{prefix} → {event.get('fn')}({args})"
|
|
163
|
+
if kind == "return":
|
|
164
|
+
return f"{prefix} ← {event.get('fn')} returned {event.get('value')}"
|
|
165
|
+
if kind == "unwind":
|
|
166
|
+
return f"{prefix} ✗ {event.get('fn')} raised {event.get('error')}"
|
|
167
|
+
location = f"{event.get('file', '?')}:{event.get('line', '?')}"
|
|
168
|
+
if name is not None:
|
|
169
|
+
value = (event.get("changed") or {}).get(name, (event.get("args") or {}).get(name))
|
|
170
|
+
return f"{prefix} {location} {name} = {value}"
|
|
171
|
+
changed = event.get("changed")
|
|
172
|
+
suffix = " " + ", ".join(f"{k}={v}" for k, v in changed.items()) if changed else ""
|
|
173
|
+
return f"{prefix} {location}{suffix}"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _cmd_debug(args: argparse.Namespace) -> int:
|
|
177
|
+
_header, events = load_log(args.log)
|
|
178
|
+
traces = [e for e in events if e.get("kind") == "trace"]
|
|
179
|
+
if not traces:
|
|
180
|
+
print("no trace events: record with run(..., debug=True)")
|
|
181
|
+
return 2
|
|
182
|
+
if args.var is not None:
|
|
183
|
+
for event in var_timeline(events, args.var):
|
|
184
|
+
if event.get("kind") == "observe":
|
|
185
|
+
print(f"step {event.get('step')!s:>4} observe {args.var} = {event.get('value')}")
|
|
186
|
+
else:
|
|
187
|
+
print(_format_trace(event, args.var))
|
|
188
|
+
return 0
|
|
189
|
+
if args.fn is not None:
|
|
190
|
+
for event in traces:
|
|
191
|
+
if event.get("event") in ("call", "return", "unwind") and args.fn in str(
|
|
192
|
+
event.get("fn", "")
|
|
193
|
+
):
|
|
194
|
+
print(_format_trace(event))
|
|
195
|
+
return 0
|
|
196
|
+
if args.step is not None:
|
|
197
|
+
during = [e for e in traces if e.get("step") == args.step]
|
|
198
|
+
print(f"=== user code executed during step {args.step} ({len(during)} events) ===")
|
|
199
|
+
for event in during:
|
|
200
|
+
print(_format_trace(event))
|
|
201
|
+
return 0
|
|
202
|
+
kinds: dict[str, int] = {}
|
|
203
|
+
for event in traces:
|
|
204
|
+
kinds[str(event.get("event"))] = kinds.get(str(event.get("event")), 0) + 1
|
|
205
|
+
steps = {e.get("step") for e in traces if e.get("step") is not None}
|
|
206
|
+
print(
|
|
207
|
+
f"{len(traces)} trace events across {len(steps)} steps "
|
|
208
|
+
f"({', '.join(f'{k}: {v}' for k, v in sorted(kinds.items()))})"
|
|
209
|
+
)
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
|
|
142
213
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
143
214
|
parser = argparse.ArgumentParser(prog="simloom", description="simloom debugging tools")
|
|
144
215
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
@@ -150,6 +221,13 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
150
221
|
trace.add_argument("--changed", default=None, help="steps where an observed value changed")
|
|
151
222
|
trace.set_defaults(func=_cmd_trace)
|
|
152
223
|
|
|
224
|
+
debug = sub.add_parser("debug", help="omniscient queries over a debug-mode log")
|
|
225
|
+
debug.add_argument("log", help="a JSONL event log recorded with debug=True")
|
|
226
|
+
debug.add_argument("--step", type=int, default=None, help="code executed during step N")
|
|
227
|
+
debug.add_argument("--var", default=None, help="full timeline of a local variable")
|
|
228
|
+
debug.add_argument("--fn", default=None, help="calls/returns of a function (substring)")
|
|
229
|
+
debug.set_defaults(func=_cmd_debug)
|
|
230
|
+
|
|
153
231
|
diff = sub.add_parser("diff", help="first divergence between two event logs")
|
|
154
232
|
diff.add_argument("a", help="first JSONL event log")
|
|
155
233
|
diff.add_argument("b", help="second JSONL event log")
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""The omniscient recorder behind ``run(..., debug=True)`` — time-travel
|
|
2
|
+
debugging v1.
|
|
3
|
+
|
|
4
|
+
A deterministic replay means a failure can be re-run *any number of times*, so
|
|
5
|
+
a debugger does not need to step forward gingerly: record **everything** once,
|
|
6
|
+
then query the recording. Under ``debug=True`` (which implies ``causal=True``),
|
|
7
|
+
a ``sys.monitoring`` recorder captures user code as it executes:
|
|
8
|
+
|
|
9
|
+
- every **line** executed, tagged with the scheduler step that ran it,
|
|
10
|
+
- every **local variable change**, as ``name = repr`` at the line it changed,
|
|
11
|
+
- every function **call** (with its arguments) and **return** (with its value).
|
|
12
|
+
|
|
13
|
+
The ``simloom debug`` CLI then answers the questions a debugger session is
|
|
14
|
+
really made of, over the whole run at once: *what code ran during step N* (with
|
|
15
|
+
its happens-before stack from the causal log), *every value ``x`` ever held and
|
|
16
|
+
where it changed*, *every call/return of a function*.
|
|
17
|
+
|
|
18
|
+
Only user code is recorded — the stdlib, site-packages, and simloom itself are
|
|
19
|
+
filtered out (and their monitoring locations disabled after first sight, so
|
|
20
|
+
the overhead concentrates on the code being debugged). Recording emits log
|
|
21
|
+
events only; it never draws from the tape or schedules callbacks, so a debug
|
|
22
|
+
run is deterministic and replays exactly (its digest differs from a non-debug
|
|
23
|
+
run — it contains more events — but is stable across debug runs).
|
|
24
|
+
|
|
25
|
+
Caveat, stated honestly: values are captured with ``repr``, so a user
|
|
26
|
+
``__repr__`` runs during recording. Reprs are truncated to keep the log sane.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
import sysconfig
|
|
35
|
+
from typing import TYPE_CHECKING, Any
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from ._loop import SimLoop
|
|
39
|
+
|
|
40
|
+
_REPR_LIMIT = 120
|
|
41
|
+
|
|
42
|
+
#: Default object reprs embed memory addresses, which differ run to run; the
|
|
43
|
+
#: event log is address-free by design (determinism rule), so scrub them.
|
|
44
|
+
_ADDRESS = re.compile(r" at 0x[0-9a-fA-F]+")
|
|
45
|
+
|
|
46
|
+
_STDLIB = sysconfig.get_paths()["stdlib"]
|
|
47
|
+
_PURELIB = sysconfig.get_paths()["purelib"]
|
|
48
|
+
_PLATLIB = sysconfig.get_paths()["platlib"]
|
|
49
|
+
_SIMLOOM = os.path.dirname(os.path.abspath(__file__))
|
|
50
|
+
_EXCLUDED_PREFIXES = tuple({_STDLIB, _PURELIB, _PLATLIB, _SIMLOOM})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _short(value: object) -> str:
|
|
54
|
+
try:
|
|
55
|
+
text = repr(value)
|
|
56
|
+
except Exception:
|
|
57
|
+
text = f"<unreprable {type(value).__name__}>"
|
|
58
|
+
text = _ADDRESS.sub(" at 0x…", text)
|
|
59
|
+
if len(text) > _REPR_LIMIT:
|
|
60
|
+
text = text[: _REPR_LIMIT - 3] + "..."
|
|
61
|
+
return text
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_user_code(filename: str) -> bool:
|
|
65
|
+
if filename.startswith("<"):
|
|
66
|
+
# "<stdin>", "<string>", pytester bodies — user; "<frozen importlib...>" — not.
|
|
67
|
+
return not filename.startswith("<frozen")
|
|
68
|
+
return not filename.startswith(_EXCLUDED_PREFIXES)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Recorder:
|
|
72
|
+
"""Installs sys.monitoring callbacks for the duration of one run."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, loop: SimLoop) -> None:
|
|
75
|
+
self._loop = loop
|
|
76
|
+
self._tool: int | None = None
|
|
77
|
+
#: id(frame) -> {name: repr} — value snapshots for live user frames.
|
|
78
|
+
#: Cleared on return/unwind, so frame-id reuse cannot cross-contaminate.
|
|
79
|
+
self._locals: dict[int, dict[str, str]] = {}
|
|
80
|
+
|
|
81
|
+
# -- lifecycle ---------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def install(self) -> None:
|
|
84
|
+
monitoring = sys.monitoring
|
|
85
|
+
for tool in (5, 4, 3, 2, 1): # first free tool id, avoiding pdb's 0
|
|
86
|
+
if monitoring.get_tool(tool) is None:
|
|
87
|
+
monitoring.use_tool_id(tool, "simloom-debugger")
|
|
88
|
+
self._tool = tool
|
|
89
|
+
break
|
|
90
|
+
if self._tool is None: # pragma: no cover — five tools already active
|
|
91
|
+
raise RuntimeError("no free sys.monitoring tool id for the simloom debugger")
|
|
92
|
+
events = monitoring.events
|
|
93
|
+
monitoring.register_callback(self._tool, events.PY_START, self._on_start)
|
|
94
|
+
monitoring.register_callback(self._tool, events.PY_RETURN, self._on_return)
|
|
95
|
+
monitoring.register_callback(self._tool, events.PY_UNWIND, self._on_unwind)
|
|
96
|
+
monitoring.register_callback(self._tool, events.LINE, self._on_line)
|
|
97
|
+
monitoring.set_events(
|
|
98
|
+
self._tool, events.PY_START | events.PY_RETURN | events.PY_UNWIND | events.LINE
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def uninstall(self) -> None:
|
|
102
|
+
if self._tool is None:
|
|
103
|
+
return
|
|
104
|
+
monitoring = sys.monitoring
|
|
105
|
+
monitoring.set_events(self._tool, 0)
|
|
106
|
+
for event in (
|
|
107
|
+
monitoring.events.PY_START,
|
|
108
|
+
monitoring.events.PY_RETURN,
|
|
109
|
+
monitoring.events.PY_UNWIND,
|
|
110
|
+
monitoring.events.LINE,
|
|
111
|
+
):
|
|
112
|
+
monitoring.register_callback(self._tool, event, None)
|
|
113
|
+
monitoring.free_tool_id(self._tool)
|
|
114
|
+
monitoring.restart_events() # re-arm locations we DISABLEd
|
|
115
|
+
self._tool = None
|
|
116
|
+
self._locals.clear()
|
|
117
|
+
|
|
118
|
+
# -- callbacks ----------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
def _emit(self, event: str, **fields: Any) -> None:
|
|
121
|
+
self._loop._log.emit(
|
|
122
|
+
"trace", t=self._loop._now, step=self._loop._current_step, event=event, **fields
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _on_start(self, code: Any, offset: int) -> Any:
|
|
126
|
+
if not _is_user_code(code.co_filename):
|
|
127
|
+
return sys.monitoring.DISABLE
|
|
128
|
+
frame = sys._getframe(1)
|
|
129
|
+
snapshot = {name: _short(value) for name, value in frame.f_locals.items()}
|
|
130
|
+
self._locals[id(frame)] = snapshot
|
|
131
|
+
self._emit(
|
|
132
|
+
"call",
|
|
133
|
+
fn=code.co_qualname,
|
|
134
|
+
file=code.co_filename,
|
|
135
|
+
line=code.co_firstlineno,
|
|
136
|
+
args=dict(snapshot),
|
|
137
|
+
)
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
def _on_line(self, code: Any, line: int) -> Any:
|
|
141
|
+
if not _is_user_code(code.co_filename):
|
|
142
|
+
return sys.monitoring.DISABLE
|
|
143
|
+
frame = sys._getframe(1)
|
|
144
|
+
snapshot = self._locals.get(id(frame))
|
|
145
|
+
if snapshot is None: # frame started before the recorder was installed
|
|
146
|
+
snapshot = {}
|
|
147
|
+
self._locals[id(frame)] = snapshot
|
|
148
|
+
changed: dict[str, str] = {}
|
|
149
|
+
for name, value in frame.f_locals.items():
|
|
150
|
+
text = _short(value)
|
|
151
|
+
if snapshot.get(name) != text:
|
|
152
|
+
snapshot[name] = text
|
|
153
|
+
changed[name] = text
|
|
154
|
+
fields: dict[str, Any] = {"file": code.co_filename, "line": line}
|
|
155
|
+
if changed:
|
|
156
|
+
fields["changed"] = changed
|
|
157
|
+
self._emit("line", **fields)
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
def _on_return(self, code: Any, offset: int, retval: Any) -> None:
|
|
161
|
+
if not _is_user_code(code.co_filename):
|
|
162
|
+
return # PY_RETURN locations for non-user code stay armed; cheap no-op
|
|
163
|
+
frame = sys._getframe(1)
|
|
164
|
+
self._locals.pop(id(frame), None)
|
|
165
|
+
self._emit("return", fn=code.co_qualname, value=_short(retval))
|
|
166
|
+
|
|
167
|
+
def _on_unwind(self, code: Any, offset: int, exc: BaseException) -> None:
|
|
168
|
+
# PY_UNWIND cannot be DISABLEd; filter and move on.
|
|
169
|
+
if not _is_user_code(code.co_filename):
|
|
170
|
+
return
|
|
171
|
+
frame = sys._getframe(1)
|
|
172
|
+
self._locals.pop(id(frame), None)
|
|
173
|
+
self._emit("unwind", fn=code.co_qualname, error=type(exc).__name__)
|
|
@@ -68,7 +68,7 @@ class SimLivelockError(SimloomError):
|
|
|
68
68
|
|
|
69
69
|
|
|
70
70
|
class ConsistencyViolation(SimloomError):
|
|
71
|
-
"""A recorded operation history is not serializable.
|
|
71
|
+
"""A recorded operation history is not serializable (or not linearizable).
|
|
72
72
|
|
|
73
73
|
The store returned a *wrong answer*, not a crash: a read saw a stale or
|
|
74
74
|
impossible value. ``cycle`` is the dependency cycle that witnesses it, with
|