simloom 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.
simloom/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ """simloom — deterministic simulation testing for asyncio.
2
+
3
+ Run unmodified asyncio programs inside a fully simulated universe: a seeded
4
+ choice tape owns every scheduling decision, the clock is virtual, and every
5
+ failure replays exactly from its recording.
6
+
7
+ Phase A surface: the deterministic loop, the choice tape, run/replay, escape
8
+ detection, and the versioned event log. The simulated world (hosts, network,
9
+ faults) and the explorer arrive in later phases — see docs/plan.md.
10
+ """
11
+
12
+ from ._buggify import draw, reached, sometimes
13
+ from ._errors import (
14
+ EscapedSimulationError,
15
+ SimDeadlockError,
16
+ SimloomError,
17
+ TapeMisalignmentError,
18
+ UnhandledExceptionError,
19
+ )
20
+ from ._eventlog import EVENT_LOG_FORMAT_VERSION, EventLog
21
+ from ._explore import Exploration, Failure, explore
22
+ from ._loop import SimLoop
23
+ from ._net import SimNetwork, SimServer, SimTransport
24
+ from ._run import RunResult, replay, run
25
+ from ._sched import PCT, RandomWalk
26
+ from ._shrink import ShrinkResult, shrink
27
+ from ._tape import TAPE_FORMAT_VERSION, Draw, MisalignmentPolicy, Tape
28
+ from ._testing import Settings, SimloomTestFailure, test
29
+ from ._version import __version__
30
+ from ._world import Host, SimDisk, World
31
+
32
+ __all__ = [
33
+ "EVENT_LOG_FORMAT_VERSION",
34
+ "PCT",
35
+ "TAPE_FORMAT_VERSION",
36
+ "Draw",
37
+ "EscapedSimulationError",
38
+ "EventLog",
39
+ "Exploration",
40
+ "Failure",
41
+ "Host",
42
+ "MisalignmentPolicy",
43
+ "RandomWalk",
44
+ "RunResult",
45
+ "Settings",
46
+ "ShrinkResult",
47
+ "SimDeadlockError",
48
+ "SimDisk",
49
+ "SimLoop",
50
+ "SimNetwork",
51
+ "SimServer",
52
+ "SimTransport",
53
+ "SimloomError",
54
+ "SimloomTestFailure",
55
+ "Tape",
56
+ "TapeMisalignmentError",
57
+ "UnhandledExceptionError",
58
+ "World",
59
+ "__version__",
60
+ "draw",
61
+ "explore",
62
+ "reached",
63
+ "replay",
64
+ "run",
65
+ "shrink",
66
+ "sometimes",
67
+ "test",
68
+ ]
simloom/_buggify.py ADDED
@@ -0,0 +1,62 @@
1
+ """Buggify: tape-drawn randomness for the code under test.
2
+
3
+ FoundationDB's trick, adapted: user code annotates rare-but-legal behaviors
4
+ (`if simloom.sometimes("drop_cache"): ...`) and the simulation explores them;
5
+ in production the same call is a constant False, so the annotations cost
6
+ nothing and never fire outside a test.
7
+
8
+ Coverage counters (``reached``, and every ``sometimes`` that fires) land in
9
+ ``RunResult.coverage`` so a corpus runner can assert that fault-handling
10
+ branches were actually exercised somewhere — the "sometimes assertion" that
11
+ catches dead recovery code.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio.events
17
+
18
+ from ._loop import SimLoop
19
+
20
+
21
+ def _sim_loop() -> SimLoop | None:
22
+ loop = asyncio.events._get_running_loop()
23
+ return loop if isinstance(loop, SimLoop) else None
24
+
25
+
26
+ def sometimes(label: str, percent: int = 25) -> bool:
27
+ """True with roughly ``percent`` probability inside a simulation;
28
+ always False outside one (safe to leave in production code)."""
29
+ if not 0 <= percent <= 100:
30
+ raise ValueError("percent must be in [0, 100]")
31
+ loop = _sim_loop()
32
+ if loop is None:
33
+ return False
34
+ fired = loop.tape.draw(f"buggify.{label}", 100) < percent
35
+ if fired:
36
+ loop.record_coverage(label)
37
+ return fired
38
+
39
+
40
+ def draw(label: str, bound: int) -> int:
41
+ """A labeled integer draw in ``[0, bound)`` from the run's choice tape.
42
+
43
+ For randomness the *program under test* needs (election timeouts, victim
44
+ picks in chaos scripts) — it replays and shrinks with everything else.
45
+ Unlike :func:`sometimes` this has no meaningful production value, so it
46
+ raises outside a simulation.
47
+ """
48
+ loop = _sim_loop()
49
+ if loop is None:
50
+ raise RuntimeError("simloom.draw() requires a running simulation")
51
+ return loop.tape.draw(label, bound)
52
+
53
+
54
+ def reached(label: str) -> None:
55
+ """Record that this point was reached (no-op outside a simulation).
56
+
57
+ Counts appear in ``RunResult.coverage``; a corpus runner can assert a
58
+ label was reached somewhere across many seeds.
59
+ """
60
+ loop = _sim_loop()
61
+ if loop is not None:
62
+ loop.record_coverage(label)
simloom/_context.py ADDED
@@ -0,0 +1,11 @@
1
+ """Context variables shared across simloom modules (no internal imports,
2
+ so anything may import this without cycles)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from contextvars import ContextVar
7
+ from typing import Any
8
+
9
+ #: The simulated host whose code is currently executing, if any. Set by
10
+ #: Host.spawn's wrapper coroutine; inherited by child tasks via contextvars.
11
+ current_host: ContextVar[Any] = ContextVar("simloom_current_host", default=None)
simloom/_errors.py ADDED
@@ -0,0 +1,54 @@
1
+ """Exception types raised by simloom."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class SimloomError(Exception):
7
+ """Base class for every error simloom raises on its own behalf."""
8
+
9
+
10
+ class EscapedSimulationError(SimloomError):
11
+ """The program under test reached for the real world from inside the sim.
12
+
13
+ Determinism only holds while every effect flows through the simulated
14
+ loop. Real sockets, file-descriptor callbacks, signal handlers, real DNS,
15
+ subprocesses — any of these would reintroduce nondeterminism silently, so
16
+ simloom turns them into this error at the exact call site instead.
17
+ """
18
+
19
+ def __init__(self, api: str, hint: str) -> None:
20
+ self.api = api
21
+ self.hint = hint
22
+ super().__init__(
23
+ f"{api} escapes the simulation: {hint} (see docs/determinism.md for the full boundary)"
24
+ )
25
+
26
+
27
+ class SimDeadlockError(SimloomError):
28
+ """The simulated universe went quiescent with work still pending.
29
+
30
+ No callback is runnable and no timer is scheduled, but the run has not
31
+ finished: every remaining task is waiting on something that can no longer
32
+ happen. This is the classic distributed-systems deadlock, caught at the
33
+ moment it forms instead of as a test timeout.
34
+ """
35
+
36
+
37
+ class TapeMisalignmentError(SimloomError):
38
+ """A replayed tape could not satisfy the draw the program asked for.
39
+
40
+ Replay re-executes the program and feeds it recorded decisions; if the
41
+ program requests a draw whose label or bound differs from what was
42
+ recorded — or runs past the end of the tape — the execution has diverged
43
+ from the recording (changed code, unpinned hash randomization, or an
44
+ escape simloom failed to catch).
45
+ """
46
+
47
+
48
+ class UnhandledExceptionError(SimloomError):
49
+ """An exception reached the loop's exception handler and nothing else.
50
+
51
+ asyncio's default behavior is to log fire-and-forget task failures and
52
+ keep going; a testing harness must not let them pass silently. Configure
53
+ with ``on_unhandled`` if a test legitimately expects orphaned failures.
54
+ """
simloom/_eventlog.py ADDED
@@ -0,0 +1,95 @@
1
+ """The event log: a versioned, public record of everything a run did.
2
+
3
+ One JSONL document per run: a header object carrying metadata, then one
4
+ object per event in execution order. The format is a public contract —
5
+ failure artifacts ship it, tooling consumes it, and the planned time-travel
6
+ debugger replays from it — so schema changes bump the version. The schema is
7
+ documented in docs/event-log.md.
8
+
9
+ The digest covers the *events only*, not the header: two runs are the same
10
+ universe iff their event sequences are byte-identical, regardless of which
11
+ machine or interpreter produced them.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ from collections.abc import Iterator, Mapping
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ EVENT_LOG_FORMAT = "simloom-events"
23
+ EVENT_LOG_FORMAT_VERSION = 1
24
+
25
+
26
+ def _canonical(obj: Mapping[str, Any]) -> str:
27
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"))
28
+
29
+
30
+ class EventLog:
31
+ """An append-only sequence of events with canonical serialization."""
32
+
33
+ __slots__ = ("_events", "metadata")
34
+
35
+ def __init__(self) -> None:
36
+ self._events: list[dict[str, Any]] = []
37
+ #: Run metadata for the header line; never part of the digest.
38
+ self.metadata: dict[str, Any] = {}
39
+
40
+ def emit(self, kind: str, t: float, **fields: Any) -> None:
41
+ """Append one event at virtual time ``t``.
42
+
43
+ Field values must be JSON-serializable and deterministic — no memory
44
+ addresses, no wall-clock times, no process-global counters.
45
+ """
46
+ event: dict[str, Any] = {"seq": len(self._events), "kind": kind, "t": t}
47
+ for key, value in fields.items():
48
+ if key in event:
49
+ raise ValueError(f"reserved event field: {key}")
50
+ event[key] = value
51
+ self._events.append(event)
52
+
53
+ # --- reading ---
54
+
55
+ @property
56
+ def events(self) -> tuple[Mapping[str, Any], ...]:
57
+ return tuple(self._events)
58
+
59
+ def __len__(self) -> int:
60
+ return len(self._events)
61
+
62
+ def __iter__(self) -> Iterator[Mapping[str, Any]]:
63
+ return iter(self._events)
64
+
65
+ def __repr__(self) -> str:
66
+ return f"<EventLog {len(self._events)} events digest={self.digest()[:12]}>"
67
+
68
+ # --- serialization ---
69
+
70
+ def header(self) -> dict[str, Any]:
71
+ return {
72
+ "format": EVENT_LOG_FORMAT,
73
+ "version": EVENT_LOG_FORMAT_VERSION,
74
+ **self.metadata,
75
+ }
76
+
77
+ def event_lines(self) -> Iterator[str]:
78
+ for event in self._events:
79
+ yield _canonical(event)
80
+
81
+ def to_jsonl(self) -> str:
82
+ lines = [_canonical(self.header())]
83
+ lines.extend(self.event_lines())
84
+ return "\n".join(lines) + "\n"
85
+
86
+ def write_to(self, path: str | Path) -> None:
87
+ Path(path).write_text(self.to_jsonl(), encoding="utf-8")
88
+
89
+ def digest(self) -> str:
90
+ """sha256 over the canonical event lines (header excluded)."""
91
+ h = hashlib.sha256()
92
+ for line in self.event_lines():
93
+ h.update(line.encode("utf-8"))
94
+ h.update(b"\n")
95
+ return h.hexdigest()
simloom/_explore.py ADDED
@@ -0,0 +1,145 @@
1
+ """The explorer: run many fresh universes and collect what broke.
2
+
3
+ Random exploration is embarrassingly parallel: with ``processes > 1`` seeds
4
+ fan out over a process pool (``main`` must then be importable — a module-
5
+ level callable). Workers report which seeds failed; the parent re-runs the
6
+ first failing seed locally so the returned artifact is exactly reproducible
7
+ in the caller's process.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable, Coroutine
13
+ from concurrent.futures import ProcessPoolExecutor
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from ._run import RunResult, run
18
+ from ._sched import SchedulerFactory
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Failure:
23
+ seed: int
24
+ error: str # exception type name
25
+ message: str
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class Exploration:
30
+ """What ``explore`` found across a corpus of seeds."""
31
+
32
+ runs: int
33
+ failures: list[Failure]
34
+ #: Full artifact for the lowest failing seed (replayable, shrinkable).
35
+ first_failure: RunResult | None
36
+ #: Union of buggify/reached counters across all runs — the data for
37
+ #: corpus-level sometimes-assertions ("was this branch ever hit?").
38
+ coverage: dict[str, int] = field(default_factory=dict)
39
+
40
+ @property
41
+ def failed(self) -> bool:
42
+ return bool(self.failures)
43
+
44
+ def summary(self) -> str:
45
+ if not self.failures:
46
+ return f"{self.runs} universes explored, none failed"
47
+ first = self.failures[0]
48
+ return (
49
+ f"{self.runs} universes explored, {len(self.failures)} failed; "
50
+ f"first: seed {first.seed} raised {first.error}: {first.message}"
51
+ )
52
+
53
+
54
+ def explore(
55
+ main: Callable[..., Coroutine[Any, Any, Any]],
56
+ *,
57
+ runs: int,
58
+ start_seed: int = 0,
59
+ stop_on_failure: bool = True,
60
+ processes: int = 1,
61
+ scheduler: str | SchedulerFactory | None = None,
62
+ **run_kwargs: Any,
63
+ ) -> Exploration:
64
+ """Run ``main`` under ``runs`` fresh seeds and report the failures."""
65
+ if runs < 1:
66
+ raise ValueError("runs must be >= 1")
67
+ seeds = range(start_seed, start_seed + runs)
68
+ if processes > 1:
69
+ return _explore_pool(main, seeds, stop_on_failure, processes, scheduler, run_kwargs)
70
+
71
+ failures: list[Failure] = []
72
+ first: RunResult | None = None
73
+ coverage: dict[str, int] = {}
74
+ executed = 0
75
+ for seed in seeds:
76
+ result = run(main, seed=seed, raise_on_error=False, scheduler=scheduler, **run_kwargs)
77
+ executed += 1
78
+ for label, count in result.coverage.items():
79
+ coverage[label] = coverage.get(label, 0) + count
80
+ if result.outcome == "error":
81
+ assert result.error is not None
82
+ failures.append(Failure(seed, type(result.error).__name__, str(result.error)[:200]))
83
+ if first is None:
84
+ first = result
85
+ if stop_on_failure:
86
+ break
87
+ return Exploration(executed, failures, first, coverage)
88
+
89
+
90
+ # --- process-pool fan-out ---
91
+
92
+
93
+ def _probe(
94
+ main: Callable[..., Coroutine[Any, Any, Any]],
95
+ seed: int,
96
+ scheduler: str | None,
97
+ run_kwargs: dict[str, Any],
98
+ ) -> tuple[int, str | None, str, dict[str, int]]:
99
+ result = run(main, seed=seed, raise_on_error=False, scheduler=scheduler, **run_kwargs)
100
+ error = type(result.error).__name__ if result.error is not None else None
101
+ message = str(result.error)[:200] if result.error is not None else ""
102
+ return seed, error, message, result.coverage
103
+
104
+
105
+ def _explore_pool(
106
+ main: Callable[..., Coroutine[Any, Any, Any]],
107
+ seeds: range,
108
+ stop_on_failure: bool,
109
+ processes: int,
110
+ scheduler: str | SchedulerFactory | None,
111
+ run_kwargs: dict[str, Any],
112
+ ) -> Exploration:
113
+ if scheduler is not None and not isinstance(scheduler, str):
114
+ raise TypeError("processes > 1 requires a string scheduler spec (picklable)")
115
+ failures: list[Failure] = []
116
+ coverage: dict[str, int] = {}
117
+ executed = 0
118
+ with ProcessPoolExecutor(max_workers=processes) as pool:
119
+ for seed, error, message, run_coverage in pool.map(
120
+ _probe,
121
+ (main for _ in seeds),
122
+ seeds,
123
+ (scheduler for _ in seeds),
124
+ (run_kwargs for _ in seeds),
125
+ chunksize=8,
126
+ ):
127
+ executed += 1
128
+ for label, count in run_coverage.items():
129
+ coverage[label] = coverage.get(label, 0) + count
130
+ if error is not None:
131
+ failures.append(Failure(seed, error, message))
132
+ if stop_on_failure:
133
+ break
134
+ failures.sort(key=lambda f: f.seed)
135
+ first: RunResult | None = None
136
+ if failures:
137
+ # Re-run locally: the artifact must reproduce in the caller's process.
138
+ first = run(
139
+ main,
140
+ seed=failures[0].seed,
141
+ raise_on_error=False,
142
+ scheduler=scheduler,
143
+ **run_kwargs,
144
+ )
145
+ return Exploration(executed, failures, first, coverage)