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.
Files changed (37) hide show
  1. {simloom-0.2.0 → simloom-0.3.0}/CHANGELOG.md +33 -1
  2. {simloom-0.2.0 → simloom-0.3.0}/PKG-INFO +15 -8
  3. {simloom-0.2.0 → simloom-0.3.0}/README.md +13 -7
  4. {simloom-0.2.0 → simloom-0.3.0}/pyproject.toml +1 -0
  5. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/__init__.py +12 -1
  6. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_cli.py +78 -0
  7. simloom-0.3.0/src/simloom/_debugger.py +173 -0
  8. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_errors.py +1 -1
  9. simloom-0.3.0/src/simloom/_linearizability.py +238 -0
  10. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_run.py +16 -1
  11. simloom-0.3.0/src/simloom/_services.py +322 -0
  12. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_version.py +1 -1
  13. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_world.py +16 -0
  14. simloom-0.2.0/src/simloom/_services.py +0 -175
  15. {simloom-0.2.0 → simloom-0.3.0}/.gitignore +0 -0
  16. {simloom-0.2.0 → simloom-0.3.0}/LICENSE +0 -0
  17. {simloom-0.2.0 → simloom-0.3.0}/examples/README.md +0 -0
  18. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_boundary.py +0 -0
  19. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_buggify.py +0 -0
  20. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_consistency.py +0 -0
  21. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_context.py +0 -0
  22. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_eventlog.py +0 -0
  23. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_explore.py +0 -0
  24. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_fingerprint.py +0 -0
  25. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_hashseed.py +0 -0
  26. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_loop.py +0 -0
  27. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_monitors.py +0 -0
  28. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_net.py +0 -0
  29. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_patches.py +0 -0
  30. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_pytest_plugin.py +0 -0
  31. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_sched.py +0 -0
  32. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_shrink.py +0 -0
  33. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_soak.py +0 -0
  34. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_systematic.py +0 -0
  35. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_tape.py +0 -0
  36. {simloom-0.2.0 → simloom-0.3.0}/src/simloom/_testing.py +0 -0
  37. {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.2.0...HEAD
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.2.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(SimRedis(), …)` gives you an in-sim Redis
201
- (SET/GET, WATCH/MULTI/EXEC) to test your logic against, with wire faults applied.
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`) catches a store that returns a stale or impossible read —
222
- not just one that crashes — and reports the cyclic read/write dependency.
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 LOG --step N`
231
- reconstructs state and the *happens-before stack* that woke it; `--changed x` is an
232
- omniscient query; `simloom diff A B` finds the first divergence between two universes.
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(SimRedis(), …)` gives you an in-sim Redis
166
- (SET/GET, WATCH/MULTI/EXEC) to test your logic against, with wire faults applied.
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`) catches a store that returns a stale or impossible read —
187
- not just one that crashes — and reports the cyclic read/write dependency.
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 LOG --step N`
196
- reconstructs state and the *happens-before stack* that woke it; `--changed x` is an
197
- omniscient query; `simloom diff A B` finds the first divergence between two universes.
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.
@@ -51,6 +51,7 @@ spikes = [
51
51
  "aiohttp>=3.11",
52
52
  "httpx>=0.28",
53
53
  "redis>=5.0",
54
+ "nats-py>=2.9",
54
55
  ]
55
56
 
56
57
  [project.scripts]
@@ -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