hydo-assert 0.2.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.
- hydo_assert/__init__.py +65 -0
- hydo_assert/_buggify.py +156 -0
- hydo_assert/_core.py +168 -0
- hydo_assert/_eventually.py +120 -0
- hydo_assert/_json.py +163 -0
- hydo_assert/_sink.py +93 -0
- hydo_assert/history.py +300 -0
- hydo_assert/py.typed +0 -0
- hydo_assert-0.2.0.dist-info/METADATA +238 -0
- hydo_assert-0.2.0.dist-info/RECORD +13 -0
- hydo_assert-0.2.0.dist-info/WHEEL +5 -0
- hydo_assert-0.2.0.dist-info/licenses/LICENSE +202 -0
- hydo_assert-0.2.0.dist-info/top_level.txt +1 -0
hydo_assert/_sink.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Where records go: the guest console, opened once.
|
|
2
|
+
|
|
3
|
+
``HYDO_ASSERT_OUT`` (read once, at the first record) selects the target, as in the Go SDK:
|
|
4
|
+
|
|
5
|
+
- unset or empty: ``/dev/ttyS0`` (the hvx guest console);
|
|
6
|
+
- a path: that existing file, opened ``O_WRONLY|O_APPEND|O_NOCTTY`` (never created);
|
|
7
|
+
- ``stderr``: standard error (file descriptor 2), i.e. plain logging outside hydo;
|
|
8
|
+
- ``off``: nowhere;
|
|
9
|
+
- ``logging`` (Python only): each record line is logged at INFO on the logger ``hydo_assert``.
|
|
10
|
+
|
|
11
|
+
If the file cannot be opened the records go to standard error. Every record is written with
|
|
12
|
+
one ``write()`` call (a loop only if the kernel accepts part of it), so it is never interleaved
|
|
13
|
+
with other output on the console.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import threading
|
|
18
|
+
|
|
19
|
+
_lock = threading.Lock()
|
|
20
|
+
_ready = False
|
|
21
|
+
_mode = "fd" # "fd", "off" or "logging"
|
|
22
|
+
_fd = 2
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _init():
|
|
26
|
+
global _ready, _mode, _fd
|
|
27
|
+
p = os.environ.get("HYDO_ASSERT_OUT", "")
|
|
28
|
+
if p == "off":
|
|
29
|
+
_mode = "off"
|
|
30
|
+
elif p == "stderr":
|
|
31
|
+
_mode, _fd = "fd", 2
|
|
32
|
+
elif p == "logging":
|
|
33
|
+
_mode = "logging"
|
|
34
|
+
else:
|
|
35
|
+
if p == "":
|
|
36
|
+
p = "/dev/ttyS0"
|
|
37
|
+
try:
|
|
38
|
+
_fd = os.open(p, os.O_WRONLY | os.O_APPEND | getattr(os, "O_NOCTTY", 0))
|
|
39
|
+
_mode = "fd"
|
|
40
|
+
except OSError:
|
|
41
|
+
_mode, _fd = "fd", 2
|
|
42
|
+
_ready = True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ensure():
|
|
46
|
+
if not _ready:
|
|
47
|
+
with _lock:
|
|
48
|
+
if not _ready:
|
|
49
|
+
_init()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def enabled():
|
|
53
|
+
"""False when HYDO_ASSERT_OUT=off (records are dropped)."""
|
|
54
|
+
_ensure()
|
|
55
|
+
return _mode != "off"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def write_line(line):
|
|
59
|
+
"""Write one record line (``line`` without the trailing newline) to the sink."""
|
|
60
|
+
_ensure()
|
|
61
|
+
if _mode == "off":
|
|
62
|
+
return
|
|
63
|
+
if _mode == "logging":
|
|
64
|
+
import logging
|
|
65
|
+
|
|
66
|
+
logging.getLogger("hydo_assert").info("%s", line)
|
|
67
|
+
return
|
|
68
|
+
data = (line + "\n").encode("utf-8", "surrogatepass")
|
|
69
|
+
try:
|
|
70
|
+
while data:
|
|
71
|
+
n = os.write(_fd, data)
|
|
72
|
+
if n <= 0:
|
|
73
|
+
break
|
|
74
|
+
data = data[n:]
|
|
75
|
+
except OSError:
|
|
76
|
+
pass # like Go's f.Write: a failed console write is not the program's problem
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _reset():
|
|
80
|
+
"""Forget the sink (tests). The next record re-reads HYDO_ASSERT_OUT."""
|
|
81
|
+
global _ready, _mode, _fd
|
|
82
|
+
with _lock:
|
|
83
|
+
if _ready and _mode == "fd" and _fd > 2:
|
|
84
|
+
try:
|
|
85
|
+
os.close(_fd)
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
_ready, _mode, _fd = False, "fd", 2
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _after_fork():
|
|
92
|
+
global _lock
|
|
93
|
+
_lock = threading.Lock()
|
hydo_assert/history.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Operation histories for hydo's host-side checkers (hydo/HISTORIES.md).
|
|
2
|
+
|
|
3
|
+
A workload client records every operation it performs -- the invocation before the request,
|
|
4
|
+
the completion after the reply -- and the host checks the whole history after the run
|
|
5
|
+
(linearizability of a register / CAS object per key, Elle-style list-append). The lines are byte
|
|
6
|
+
for byte those of the Go recorder (sdk/go/hydohist)::
|
|
7
|
+
|
|
8
|
+
HYDO-HIST <history> <process> <invoke|ok|fail|info> <time-ns> <f> <arg>...
|
|
9
|
+
|
|
10
|
+
Use::
|
|
11
|
+
|
|
12
|
+
from hydo_assert import history as hh
|
|
13
|
+
|
|
14
|
+
kv = hh.History("kv")
|
|
15
|
+
op = kv.invoke(3, "write", "k1", hh.enc(v)) # process 3, before the request
|
|
16
|
+
try:
|
|
17
|
+
rev = put("k1", v)
|
|
18
|
+
op.ok("k1", hh.ver(v, rev)) # the version the store assigned, if any
|
|
19
|
+
except Rejected:
|
|
20
|
+
op.fail() # definitely had no effect
|
|
21
|
+
except Exception:
|
|
22
|
+
op.info() # a timeout: unknown outcome
|
|
23
|
+
op = kv.invoke(3, "read", "k1")
|
|
24
|
+
op.ok("k1", hh.value_or_nil(get("k1"))) # None = absent
|
|
25
|
+
|
|
26
|
+
One record = one line = one ``os.write`` to ``/dev/console``; ``HYDO_HIST_OUT=<path>|stderr|off``
|
|
27
|
+
overrides it (the assertion records keep ``HYDO_ASSERT_OUT``). A path is opened
|
|
28
|
+
``O_WRONLY|O_APPEND|O_CREAT|O_NOCTTY`` (mode 0644), once, at the first record; if it cannot be
|
|
29
|
+
opened the records go to standard error. The host orders operations by the position of their
|
|
30
|
+
records, so record the invocation before sending and the completion after receiving. A process
|
|
31
|
+
has at most one operation in flight: give each concurrent client (thread, task) its own process
|
|
32
|
+
number. The time field is CLOCK_MONOTONIC in ns (``time.monotonic_ns()``, deterministic under
|
|
33
|
+
hydo); the checkers do not order by it.
|
|
34
|
+
|
|
35
|
+
Text: tokens are written as UTF-8. A ``str`` is encoded with the ``surrogateescape`` rule, so a
|
|
36
|
+
string that came from bytes that way (``os.fsdecode``, ``sys.argv``, ``os.environ``, or the result
|
|
37
|
+
of ``enc`` on bytes) gives back its original bytes; any other lone surrogate is written as U+FFFD.
|
|
38
|
+
``bytes`` are accepted wherever a ``str`` is and are taken as they are, so invalid UTF-8 can be
|
|
39
|
+
recorded exactly as Go records it.
|
|
40
|
+
|
|
41
|
+
This module is not imported by ``hydo_assert`` itself: importing the assertion SDK writes and
|
|
42
|
+
opens nothing new.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import operator
|
|
46
|
+
import os
|
|
47
|
+
import threading
|
|
48
|
+
import time
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"NIL",
|
|
52
|
+
"History",
|
|
53
|
+
"Op",
|
|
54
|
+
"enc",
|
|
55
|
+
"ver",
|
|
56
|
+
"value_or_nil",
|
|
57
|
+
"list_value",
|
|
58
|
+
"append_mop",
|
|
59
|
+
"read_mop",
|
|
60
|
+
"read_result_mop",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
#: The value of an absent key (register model); the empty list is ``[]``.
|
|
64
|
+
NIL = "nil"
|
|
65
|
+
|
|
66
|
+
_INT64_MIN = -(1 << 63)
|
|
67
|
+
_INT64_MAX = (1 << 63) - 1
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _utf8(s):
|
|
71
|
+
"""The bytes of a str: UTF-8 with surrogateescape; other lone surrogates become U+FFFD."""
|
|
72
|
+
try:
|
|
73
|
+
return s.encode("utf-8", "surrogateescape")
|
|
74
|
+
except UnicodeEncodeError:
|
|
75
|
+
out = bytearray()
|
|
76
|
+
for ch in s:
|
|
77
|
+
o = ord(ch)
|
|
78
|
+
if 0xDC80 <= o <= 0xDCFF:
|
|
79
|
+
out.append(o - 0xDC00)
|
|
80
|
+
elif 0xD800 <= o <= 0xDFFF:
|
|
81
|
+
out += b"\xef\xbf\xbd"
|
|
82
|
+
else:
|
|
83
|
+
out += ch.encode("utf-8")
|
|
84
|
+
return bytes(out)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _int(x, what):
|
|
88
|
+
"""An int64 in decimal, as Go's strconv.FormatInt writes it (bool is refused)."""
|
|
89
|
+
try:
|
|
90
|
+
if isinstance(x, bool):
|
|
91
|
+
raise TypeError
|
|
92
|
+
x = operator.index(x)
|
|
93
|
+
except TypeError:
|
|
94
|
+
raise TypeError("hydo_assert.history: %s must be an int, got %s" % (what, type(x).__name__)) from None
|
|
95
|
+
if not _INT64_MIN <= x <= _INT64_MAX:
|
|
96
|
+
raise ValueError("hydo_assert.history: %s %d is outside int64" % (what, x))
|
|
97
|
+
return str(int(x))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _token(x, what="argument"):
|
|
101
|
+
"""A record argument: a str (one token already), bytes (taken as they are) or an int."""
|
|
102
|
+
if isinstance(x, str):
|
|
103
|
+
return x
|
|
104
|
+
if isinstance(x, (bytes, bytearray, memoryview)):
|
|
105
|
+
return bytes(x).decode("utf-8", "surrogateescape")
|
|
106
|
+
return _int(x, what)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# Bytes 0x00-0x7f: the byte itself, or %XX for '%', '@', whitespace and control characters
|
|
110
|
+
# (<= ' '), DEL and the list / txn separators ',[]:' (Go's Enc). Bytes >= 0x80 stay.
|
|
111
|
+
_TABLE = []
|
|
112
|
+
for _c in range(256):
|
|
113
|
+
if _c == 0x25 or _c == 0x40 or _c <= 0x20 or _c == 0x7F or _c in b",[]:":
|
|
114
|
+
_TABLE.append(("%%%02X" % _c).encode("ascii"))
|
|
115
|
+
else:
|
|
116
|
+
_TABLE.append(bytes((_c,)))
|
|
117
|
+
del _c
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def enc(s):
|
|
121
|
+
"""One token from a value, as Go's ``hydohist.Enc`` makes it from the value's bytes.
|
|
122
|
+
|
|
123
|
+
'%', '@', whitespace, control characters, DEL and ``,[]:`` become ``%XX``; other bytes
|
|
124
|
+
(all of non-ASCII UTF-8, and invalid bytes given as ``bytes``) stay. ``""`` is ``"%"`` and
|
|
125
|
+
``"nil"`` is ``"%6Eil"`` (the token ``nil`` means absent). ``s`` is a str, bytes or an int.
|
|
126
|
+
"""
|
|
127
|
+
if isinstance(s, str):
|
|
128
|
+
b = _utf8(s)
|
|
129
|
+
elif isinstance(s, (bytes, bytearray, memoryview)):
|
|
130
|
+
b = bytes(s)
|
|
131
|
+
else:
|
|
132
|
+
b = _int(s, "enc value").encode("ascii")
|
|
133
|
+
if b == b"":
|
|
134
|
+
return "%"
|
|
135
|
+
if b == b"nil":
|
|
136
|
+
return "%6Eil"
|
|
137
|
+
t = _TABLE
|
|
138
|
+
return b"".join([t[c] for c in b]).decode("utf-8", "surrogateescape")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def ver(v, version):
|
|
142
|
+
"""Value ``v`` (encoded) with the version the store assigned it (an int64): ``"v@83"``."""
|
|
143
|
+
return enc(v) + "@" + _int(version, "version")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def value_or_nil(v):
|
|
147
|
+
"""``enc(v)``, or ``NIL`` when ``v`` is None (Go's ``ValOrNil(v, ok)`` with ok = v is not None)."""
|
|
148
|
+
return NIL if v is None else enc(v)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def list_value(*elems):
|
|
152
|
+
"""A list value ``"[e1,e2,...]"`` (list-append reads), each element encoded.
|
|
153
|
+
|
|
154
|
+
Go and Rust call it ``List`` / ``list``; here a function named ``list`` would hide the
|
|
155
|
+
builtin for every ``from hydo_assert.history import *`` user.
|
|
156
|
+
"""
|
|
157
|
+
return "[" + ",".join([enc(e) for e in elems]) + "]"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def append_mop(k, e):
|
|
161
|
+
"""The list-append txn micro-op ``"a:<k>:<e>"``."""
|
|
162
|
+
return "a:" + enc(k) + ":" + enc(e)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def read_mop(k):
|
|
166
|
+
"""The list-append txn micro-op ``"r:<k>"`` (invocation)."""
|
|
167
|
+
return "r:" + enc(k)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def read_result_mop(k, *l):
|
|
171
|
+
"""The list-append txn micro-op ``"r:<k>:[...]"`` (completion)."""
|
|
172
|
+
return "r:" + enc(k) + ":" + list_value(*l)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------------------------
|
|
176
|
+
# Sink: opened once, at the first record.
|
|
177
|
+
|
|
178
|
+
_lock = threading.Lock()
|
|
179
|
+
_ready = False
|
|
180
|
+
_off = False
|
|
181
|
+
_fd = 2
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _init():
|
|
185
|
+
global _ready, _off, _fd
|
|
186
|
+
p = os.environ.get("HYDO_HIST_OUT", "")
|
|
187
|
+
_off, _fd = False, 2
|
|
188
|
+
if p == "off":
|
|
189
|
+
_off = True
|
|
190
|
+
elif p != "stderr":
|
|
191
|
+
if p == "":
|
|
192
|
+
p = "/dev/console"
|
|
193
|
+
try:
|
|
194
|
+
_fd = os.open(p, os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOCTTY", 0), 0o644)
|
|
195
|
+
except OSError:
|
|
196
|
+
_fd = 2
|
|
197
|
+
_ready = True
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _write(name, proc, typ, f, args):
|
|
201
|
+
if not _ready:
|
|
202
|
+
with _lock:
|
|
203
|
+
if not _ready:
|
|
204
|
+
_init()
|
|
205
|
+
if _off:
|
|
206
|
+
return
|
|
207
|
+
parts = [name, proc, typ, None, f]
|
|
208
|
+
for a in args:
|
|
209
|
+
parts.append(a if a != "" else "%") # the empty string (enc("") gives it too)
|
|
210
|
+
with _lock:
|
|
211
|
+
# The time is read under the lock, so the times of the records on one console never
|
|
212
|
+
# decrease (Go reads it just before taking its lock; the format is the same).
|
|
213
|
+
parts[3] = str(time.monotonic_ns())
|
|
214
|
+
data = _utf8("HYDO-HIST " + " ".join(parts) + "\n")
|
|
215
|
+
try:
|
|
216
|
+
while data: # one write(); a short write (never on the console) is continued
|
|
217
|
+
n = os.write(_fd, data)
|
|
218
|
+
if n <= 0:
|
|
219
|
+
break
|
|
220
|
+
data = data[n:]
|
|
221
|
+
except OSError:
|
|
222
|
+
pass # like Go's f.Write: a failed console write is ignored
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _reset():
|
|
226
|
+
"""Forget the sink (tests). The next record re-reads HYDO_HIST_OUT."""
|
|
227
|
+
global _ready, _off, _fd
|
|
228
|
+
with _lock:
|
|
229
|
+
if _ready and not _off and _fd > 2:
|
|
230
|
+
try:
|
|
231
|
+
os.close(_fd)
|
|
232
|
+
except OSError:
|
|
233
|
+
pass
|
|
234
|
+
_ready, _off, _fd = False, False, 2
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _after_fork():
|
|
238
|
+
# The child inherits the descriptor; a lock held by another thread at the fork is not.
|
|
239
|
+
global _lock
|
|
240
|
+
_lock = threading.Lock()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if hasattr(os, "register_at_fork"):
|
|
244
|
+
os.register_at_fork(after_in_child=_after_fork)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class History(object):
|
|
248
|
+
"""One named history (a check's ``history:``; ``[A-Za-z0-9_.-]+``)."""
|
|
249
|
+
|
|
250
|
+
__slots__ = ("name",)
|
|
251
|
+
|
|
252
|
+
def __init__(self, name):
|
|
253
|
+
self.name = str(name)
|
|
254
|
+
|
|
255
|
+
def invoke(self, proc, f, *args):
|
|
256
|
+
"""Record the invocation of ``f(args...)`` by process ``proc`` and return the operation.
|
|
257
|
+
|
|
258
|
+
``proc`` is an int or a token without spaces. ``args`` are tokens: encode free-form
|
|
259
|
+
values with ``enc`` (``ver``, ``list_value`` and ``NIL`` help); ints are written in
|
|
260
|
+
decimal, bytes as they are.
|
|
261
|
+
"""
|
|
262
|
+
op = Op(self, _token(proc, "process"), str(f), tuple(_token(a) for a in args))
|
|
263
|
+
_write(self.name, op.proc, "invoke", op.f, op.args)
|
|
264
|
+
return op
|
|
265
|
+
|
|
266
|
+
def record(self, proc, typ, f, *args):
|
|
267
|
+
"""Write one raw record (for programs that keep their own bookkeeping)."""
|
|
268
|
+
_write(self.name, _token(proc, "process"), str(typ), str(f), [_token(a) for a in args])
|
|
269
|
+
|
|
270
|
+
def __repr__(self):
|
|
271
|
+
return "History(%r)" % (self.name,)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class Op(object):
|
|
275
|
+
"""An invoked operation waiting for its completion."""
|
|
276
|
+
|
|
277
|
+
__slots__ = ("history", "proc", "f", "args")
|
|
278
|
+
|
|
279
|
+
def __init__(self, history, proc, f, args):
|
|
280
|
+
self.history = history
|
|
281
|
+
self.proc = proc
|
|
282
|
+
self.f = f
|
|
283
|
+
self.args = args
|
|
284
|
+
|
|
285
|
+
def ok(self, *args):
|
|
286
|
+
"""A successful completion: the results in the invocation's layout (a read lists
|
|
287
|
+
``<key> <value>`` pairs, a txn its micro-ops with read results); with no args the
|
|
288
|
+
invocation's are repeated."""
|
|
289
|
+
_write(self.history.name, self.proc, "ok", self.f, [_token(a) for a in args] if args else self.args)
|
|
290
|
+
|
|
291
|
+
def fail(self):
|
|
292
|
+
"""The operation definitely had no effect."""
|
|
293
|
+
_write(self.history.name, self.proc, "fail", self.f, self.args)
|
|
294
|
+
|
|
295
|
+
def info(self):
|
|
296
|
+
"""The outcome is unknown (it may take effect at any later time, or never)."""
|
|
297
|
+
_write(self.history.name, self.proc, "info", self.f, self.args)
|
|
298
|
+
|
|
299
|
+
def __repr__(self):
|
|
300
|
+
return "Op(%r, %r, %r, %r)" % (self.history.name, self.proc, self.f, self.args)
|
hydo_assert/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hydo-assert
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Assertion SDK for programs tested under hydo, a deterministic hypervisor (always/sometimes properties, BUGGIFY fault points, bounded liveness, operation histories)
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/kakoc/hydo
|
|
7
|
+
Project-URL: Documentation, https://github.com/kakoc/hydo/tree/main/sdk/python#readme
|
|
8
|
+
Project-URL: Repository, https://github.com/kakoc/hydo
|
|
9
|
+
Project-URL: Issues, https://github.com/kakoc/hydo/issues
|
|
10
|
+
Keywords: testing,assertions,deterministic,fault-injection,simulation,hydo
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# hydo-assert: the Python assertion SDK
|
|
24
|
+
|
|
25
|
+
A Python program running under hydo (the deterministic hypervisor of
|
|
26
|
+
[github.com/kakoc/hydo](https://github.com/kakoc/hydo)) uses this package to say what must be
|
|
27
|
+
true and what should happen at least once. It writes the same console records as the Go SDK
|
|
28
|
+
(`sdk/go`), byte for byte, with the same deduplication rules, so `hydo run` and the explorer
|
|
29
|
+
(`hydo explore`) read them unchanged. The wire format and the verdict
|
|
30
|
+
rules are in `hydo/ASSERTIONS.md` of that repository; paths below are relative to it.
|
|
31
|
+
|
|
32
|
+
- Distribution name `hydo-assert`, import name `hydo_assert`.
|
|
33
|
+
- Standard library only; Python 3.8 or later; thread-safe and safe to call from asyncio code.
|
|
34
|
+
- Install it from PyPI (`pip install hydo-assert`), or from a checkout (`pip install ./sdk/python`),
|
|
35
|
+
or put `sdk/python/src` on `PYTHONPATH`, or copy `src/hydo_assert/` into the guest image (the
|
|
36
|
+
`hydo/onboard/examples/python-kv` example does that).
|
|
37
|
+
|
|
38
|
+
## Use
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import hydo_assert as ha
|
|
42
|
+
|
|
43
|
+
# Declare at module level: the catalog is complete once the module is imported.
|
|
44
|
+
ACKED = ha.always("kv.acked_durable", "every acknowledged PUT reads back with its value")
|
|
45
|
+
PARSES = ha.always_or_unreachable("kv.log_parses", "every complete log line parses on replay")
|
|
46
|
+
SIZE = ha.always("kv.log_size", "the log holds at most 10000 entries")
|
|
47
|
+
REPLAYED = ha.sometimes("kv.recovered", "the server replayed a non-empty log at start")
|
|
48
|
+
TORN = ha.reachable("kv.torn_tail", "the log ended in a partial line")
|
|
49
|
+
BAD = ha.unreachable("kv.bad_command", "the server got a command it does not know")
|
|
50
|
+
BACK = ha.eventually("kv.back", "the server answers within 5 s", 5.0) # seconds or timedelta
|
|
51
|
+
LOST = ha.buggify("kv.put.lost_reply", "the PUT is logged but its reply is lost")
|
|
52
|
+
|
|
53
|
+
ha.catalog() # once, early: the HYDO-ASSERT-DECL lines
|
|
54
|
+
ACKED.check(got == want, {"key": k, "got": got}) # returns bool(cond)
|
|
55
|
+
REPLAYED.check(n > 0, {"entries": n})
|
|
56
|
+
TORN.hit({"bytes": len(tail)})
|
|
57
|
+
BAD.hit()
|
|
58
|
+
SIZE.cmp(n, "<=", 10000) # details: left, op, right
|
|
59
|
+
if LOST.fire({"key": k}): # BUGGIFY fault point
|
|
60
|
+
return
|
|
61
|
+
BACK.await_cond(lambda: ping() == "PONG", poll=0.2) # blocks; one record
|
|
62
|
+
w = BACK.begin(); ...; w.done() # or: anchor now, condition true now / w.expire()
|
|
63
|
+
ha.setup_complete() # HYDO-SETUP-COMPLETE
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
In asyncio code use `await BACK.await_async(cond, poll)`: `cond` may be a plain function or an
|
|
67
|
+
`async def`, and the wait between checks is `asyncio.sleep`. Every other call is a short write
|
|
68
|
+
to the console under one process-wide lock that is never held across a blocking call or an
|
|
69
|
+
`await`, so it is safe from coroutines and from threads.
|
|
70
|
+
|
|
71
|
+
| Go SDK (`hydoassert`) | Python (`hydo_assert`) |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `Always(id, msg)`, `AlwaysOrUnreachable`, `Sometimes`, `Reachable`, `Unreachable` | `always(id, msg)`, `always_or_unreachable`, `sometimes`, `reachable`, `unreachable` (return an `Assertion`) |
|
|
74
|
+
| `a.Check(cond, d)`, `a.Hit(d)` | `a.check(cond, details=None)`, `a.hit(details=None)` |
|
|
75
|
+
| `a.Cmp(left, op, right, d)` | `a.cmp(left, op, right, details=None)` (`op` is one of `<`, `<=`, `==`, `!=`, `>=`, `>`) |
|
|
76
|
+
| `Eventually(id, msg, within)`, `l.Await(cond, poll, d)`, `l.Begin()`, `w.Done(d)`, `w.Expire(d)` | `eventually(id, msg, within)`, `l.await_cond(cond, poll, details=None)`, `await l.await_async(cond, poll, details=None)`, `l.begin()`, `w.done(details=None)`, `w.expire(details=None)` |
|
|
77
|
+
| `Buggify(id, msg)`, `p.Fire(d)` | `buggify(id, msg)`, `p.fire(details=None)` |
|
|
78
|
+
| `Catalog()`, `SetupComplete()` | `catalog()`, `setup_complete()` |
|
|
79
|
+
| `D{...}` | any mapping (a `dict`); `hydo_assert.D` is `dict` |
|
|
80
|
+
|
|
81
|
+
The naming follows the other SDKs (`sdk/js`, `sdk/rust`): the Go names in Python's snake case,
|
|
82
|
+
and `await_cond` / `await_async` for Go's `Await`. That table, `Liveness`, `Watch` and `Point`
|
|
83
|
+
(the classes the calls return) and the history recorder below are the whole API: a comparison
|
|
84
|
+
has one form, `cmp` with its operator, and names that start with `_` (the modules `_core`,
|
|
85
|
+
`_buggify`, `_eventually`, `_json`, `_sink`) are the package's own.
|
|
86
|
+
|
|
87
|
+
## Records, deduplication, catalog
|
|
88
|
+
|
|
89
|
+
- Records go to `/dev/ttyS0` (opened once, `O_WRONLY|O_APPEND|O_NOCTTY`, one `write` per
|
|
90
|
+
record). `HYDO_ASSERT_OUT=<path>` writes to that existing file, `stderr` to standard error
|
|
91
|
+
(plain logging outside hydo), `off` nowhere, and `logging` (Python only) logs each record line
|
|
92
|
+
at INFO on the logger `hydo_assert`. If the file cannot be opened, standard error is used.
|
|
93
|
+
- A process writes each (assertion, outcome) pair once: the first pass and the first failure,
|
|
94
|
+
with the details of that evaluation. A `sometimes` that evaluated false is never written.
|
|
95
|
+
`unreachable` is written with `ok:false`. A record whose details hold a NaN or an infinity is
|
|
96
|
+
not written at all, but still counts as written (as in Go, where `json.Marshal` fails after
|
|
97
|
+
the pair was marked).
|
|
98
|
+
- The catalog (`catalog()`) lists every declared assertion in declaration order: the order in
|
|
99
|
+
which the declaring statements ran, which for module-level declarations is the import order.
|
|
100
|
+
A module that is never imported contributes nothing; import it before calling `catalog()`.
|
|
101
|
+
Call `catalog()` in the process whose console output is collected for each run (for a
|
|
102
|
+
server started before the explorer's snapshot point, that is a client or the oracle, not the
|
|
103
|
+
server; see the example).
|
|
104
|
+
- Details are encoded exactly as Go's `encoding/json` encodes the equivalent Go value: keys in
|
|
105
|
+
UTF-8 byte order, `<`, `>`, `&`, U+2028 and U+2029 escaped, other non-ASCII text raw, Go's
|
|
106
|
+
float format (`1e+21`, `1.5e-7`, `3` for `3.0`, `-0`). The caller's dict is never changed
|
|
107
|
+
(Go adds `left`/`op`/`right`, `hit`, `elapsed_ms` to the caller's map; Python adds them to a
|
|
108
|
+
copy, which gives the same record).
|
|
109
|
+
- Python values Go has no form for: a tuple is an array; `bytes` is a base64 string (as Go
|
|
110
|
+
encodes `[]byte`); a set is an array sorted by encoded element; integer dict keys are written
|
|
111
|
+
in decimal; any other object is the string `str(obj)`. A lone surrogate (an undecodable byte
|
|
112
|
+
read with `surrogateescape`) is written as `\ufffd`, as the pinned guest Go 1.26.5 writes an
|
|
113
|
+
invalid UTF-8 byte (Go 1.27 writes a raw U+FFFD instead).
|
|
114
|
+
- `details` that is not a mapping is wrapped as `{"value": details}`.
|
|
115
|
+
- After `os.fork()` the child keeps the parent's deduplication state (it is one program's
|
|
116
|
+
history) and gets fresh locks.
|
|
117
|
+
|
|
118
|
+
## BUGGIFY
|
|
119
|
+
|
|
120
|
+
Same decisions as the Go SDK: configured by `$HYDO_BUGGIFY`, or else the file
|
|
121
|
+
`$HYDO_BUGGIFY_FILE` (default `/tmp/hydo-buggify`), holding
|
|
122
|
+
`<seed-hex>[,<enable-permille>[,<fire-permille>]]` (defaults 250 and 250, FoundationDB's
|
|
123
|
+
values), parsed as Go's `strconv.ParseUint` does (no `0x`, no sign, no spaces inside a field;
|
|
124
|
+
an unparsable seed leaves BUGGIFY off, an unparsable permille keeps its default). It is read
|
|
125
|
+
once, at the first evaluation of any point, which also writes
|
|
126
|
+
`HYDO-BUGGIFY {"seed":..,"enable_pm":..,"fire_pm":..,"enabled":[..]}`. A point is enabled
|
|
127
|
+
for the run when `splitmix64(seed ^ fnv1a(id)) % 1000 < enable`; its k-th evaluation fires
|
|
128
|
+
when `splitmix64(splitmix64(seed ^ fnv1a(id)) + k·0x9e3779b97f4a7c15) % 1000 < fire`. Each
|
|
129
|
+
point is also a `sometimes` property `buggify.<id>` ("fault point fired: <msg>"), recorded at
|
|
130
|
+
its first firing with `{"hit": k}`. Without a configuration every point returns False and has
|
|
131
|
+
no other effect. Under hydo the schedule token `B=<seed>` (`hydo explore --buggify`) reaches the
|
|
132
|
+
file through the guest init: an onboarded project gets it with `buggify: true` in its
|
|
133
|
+
`hydo.yaml` (`hydo/onboard/MANIFEST.md`).
|
|
134
|
+
|
|
135
|
+
## History recorder
|
|
136
|
+
|
|
137
|
+
The module `hydo_assert.history` records operation histories for hydo's host-side checkers
|
|
138
|
+
(linearizability of a register or compare-and-set object per key, and Elle-style list-append;
|
|
139
|
+
the format and the checkers are in `hydo/HISTORIES.md`). It writes the same `HYDO-HIST` lines as
|
|
140
|
+
the Go recorder (`sdk/go/hydohist`), byte for byte except the time field. It is a separate
|
|
141
|
+
module: `import hydo_assert` does not load it, so the assertion records are unchanged.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from hydo_assert import history as hh
|
|
145
|
+
|
|
146
|
+
kv = hh.History("kv")
|
|
147
|
+
op = kv.invoke(3, "write", "k1", hh.enc(v)) # process 3, before the request
|
|
148
|
+
try:
|
|
149
|
+
rev = put("k1", v)
|
|
150
|
+
op.ok("k1", hh.ver(v, rev)) # the version the store assigned, if any
|
|
151
|
+
except Rejected:
|
|
152
|
+
op.fail() # it definitely had no effect
|
|
153
|
+
except Exception:
|
|
154
|
+
op.info() # a timeout: the outcome is unknown
|
|
155
|
+
kv.invoke(3, "read", "k1").ok("k1", hh.value_or_nil(get("k1"))) # None means absent
|
|
156
|
+
|
|
157
|
+
la = hh.History("la")
|
|
158
|
+
tx = la.invoke(4, "txn", hh.append_mop("x", "1"), hh.read_mop("y"))
|
|
159
|
+
tx.ok(hh.append_mop("x", "1"), hh.read_result_mop("y", "1", "2"))
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
| Go (`hydohist`) | Python (`hydo_assert.history`) |
|
|
163
|
+
|---|---|
|
|
164
|
+
| `New(name)` | `History(name)` |
|
|
165
|
+
| `h.Invoke(proc, f, args...)`, `h.Record(proc, typ, f, args...)` | `h.invoke(proc, f, *args)`, `h.record(proc, typ, f, *args)` |
|
|
166
|
+
| `op.Ok(args...)`, `op.Fail()`, `op.Info()` | `op.ok(*args)` (no args repeats the invocation's), `op.fail()`, `op.info()` |
|
|
167
|
+
| `Enc`, `Ver`, `ValOrNil(v, ok)`, `List` | `enc`, `ver`, `value_or_nil(v)` (None means absent), `list_value` |
|
|
168
|
+
| `Append`, `Read`, `ReadResult`, `Nil` | `append_mop`, `read_mop`, `read_result_mop`, `NIL` |
|
|
169
|
+
|
|
170
|
+
- `list_value` has a longer name than Go's `List` so that it does not hide the builtin `list`
|
|
171
|
+
for a program that uses `from hydo_assert.history import *`. The micro-op helpers have the
|
|
172
|
+
Rust SDK's names.
|
|
173
|
+
- A process is an int or a token without spaces. Arguments are tokens: a `str` is written as
|
|
174
|
+
it is (encode free-form values with `enc`), an `int` is written in decimal, and `bytes` are
|
|
175
|
+
written as they are. Versions are int64 values; a value outside that range raises
|
|
176
|
+
`ValueError`, and a value that is not an int (including `bool`) raises `TypeError`.
|
|
177
|
+
- Records go to `/dev/console`. `HYDO_HIST_OUT=<path>` appends to that file and creates it if
|
|
178
|
+
it is missing (mode 0644), `stderr` writes to standard error, and `off` writes nothing. If the
|
|
179
|
+
file cannot be opened, standard error is used. The setting is read once, at the first record,
|
|
180
|
+
and is separate from `HYDO_ASSERT_OUT`.
|
|
181
|
+
- Each record is one `os.write` call made under a lock, so threads never interleave lines. The
|
|
182
|
+
time field is `time.monotonic_ns()` (CLOCK_MONOTONIC on Linux, deterministic under hydo). It
|
|
183
|
+
is read under the lock, so the times of successive lines never decrease; Go reads it just
|
|
184
|
+
before its lock, and the line format is the same. A forked child gets a new lock and keeps the
|
|
185
|
+
descriptor.
|
|
186
|
+
- Text is written as UTF-8. A `str` is encoded with the `surrogateescape` rule, so a string
|
|
187
|
+
that came from bytes that way (`os.fsdecode`, `sys.argv`, `os.environ`, or `enc` of bytes)
|
|
188
|
+
gives back its original bytes. Any other lone surrogate is written as U+FFFD. `enc` of
|
|
189
|
+
`bytes` keeps invalid UTF-8 bytes as they are, as Go's `Enc` does, and returns them as
|
|
190
|
+
surrogateescape characters.
|
|
191
|
+
- A process has at most one operation in flight: give each concurrent client (thread or task)
|
|
192
|
+
its own process number. Record the invocation before sending the request and the completion
|
|
193
|
+
after the reply, because the checkers order operations by the position of their records.
|
|
194
|
+
|
|
195
|
+
## Determinism
|
|
196
|
+
|
|
197
|
+
The records are a pure function of the program's evaluation sequence: no clocks (except the
|
|
198
|
+
guest's monotonic clock for `eventually`, which hvx makes deterministic), no host state,
|
|
199
|
+
catalog in declaration order, sorted keys. A Python program is itself deterministic under hydo:
|
|
200
|
+
the guest CRNG is seeded identically every run, so even the hash seed is the same; setting
|
|
201
|
+
`PYTHONHASHSEED=0` makes that independent of the CRNG. `PYTHONDONTWRITEBYTECODE=1` avoids
|
|
202
|
+
writing `.pyc` files on the guest's read-only root.
|
|
203
|
+
|
|
204
|
+
## Tests
|
|
205
|
+
|
|
206
|
+
- `python3 -m unittest discover -s sdk/python/tests`: the encoder (Go's float and string
|
|
207
|
+
forms), deduplication, details not mutated, NaN, catalog order, 16 threads writing 2000
|
|
208
|
+
evaluations each without an interleaved line, asyncio `eventually`, fork, BUGGIFY decisions
|
|
209
|
+
and rates. Passes on Python 3.8.20 and 3.15.0.
|
|
210
|
+
- `tests/test_cases.py` (run by the same `unittest` command): the shared cases (`sdk/cases`,
|
|
211
|
+
written by the reference SDK, `sdk/rust`; format and rules in `sdk/rust/README.md`, "The
|
|
212
|
+
shared cases"). For every case and run it starts itself as a child process, makes the
|
|
213
|
+
case's calls with this package and `hydo_assert.history`, and compares the bytes the sink
|
|
214
|
+
received and the calls' results with the case's files: every assertion type, comparisons,
|
|
215
|
+
details with tricky values (invalid UTF-8 as `surrogateescape` characters), the catalog,
|
|
216
|
+
`eventually`, BUGGIFY under 18 configurations, `HYDO_ASSERT_OUT`, and the
|
|
217
|
+
history records with every helper and `HYDO_HIST_OUT` setting. Cases that need a float32
|
|
218
|
+
(`f32`) are skipped and listed. To add a case, add it to the Rust SDK's
|
|
219
|
+
`tests/cases/defs.rs` and run `HYDO_BLESS=1 cargo test --test cases` there; this test then
|
|
220
|
+
runs it. A format change starts in the Rust SDK too: after it is blessed, this test fails on
|
|
221
|
+
the lines this package must change. No other language is needed to run it. Passes on Python
|
|
222
|
+
3.8.20 and 3.15.0.
|
|
223
|
+
- `tests/test_history.py` (run by the same `unittest` command): the exact lines of the Go
|
|
224
|
+
recorder's own unit test, the encoding of every byte class, the argument checks, the sink
|
|
225
|
+
settings, one `os.write` per record, 8 threads writing 400 records each without an
|
|
226
|
+
interleaved line, and fork.
|
|
227
|
+
- The files of `src/hydo_assert/` are copied into the python-kv example's images, so a change
|
|
228
|
+
to any of them changes those images' bytes and every python-kv fingerprint (the last such
|
|
229
|
+
change, a reworded comment in `_core.py`, re-recorded them in milestone 157;
|
|
230
|
+
docs/REBASELINE.md has the values).
|
|
231
|
+
|
|
232
|
+
## Limits
|
|
233
|
+
|
|
234
|
+
- No call-site form that registers itself before it runs (the Rust SDK's macros do that with a
|
|
235
|
+
linker section). A Python assertion is in the catalog once the statement that declares it has
|
|
236
|
+
run, so declare at module level.
|
|
237
|
+
- `cmp` compares any two Python values that support the operator; values that cannot be
|
|
238
|
+
compared evaluate to false. Go's `Cmp` takes only `int64`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
hydo_assert/__init__.py,sha256=PIsdaxsu7pYz9O0IazFp3FXxYRXqD3hZgylfVTRcJso,2251
|
|
2
|
+
hydo_assert/_buggify.py,sha256=83PHDJ-A-9ekGroZYsrlN_Oh3GVr92nddNIwKSCXoh8,5215
|
|
3
|
+
hydo_assert/_core.py,sha256=G7gEXpJKKUE6GiyAI-F5my_41Tcenu876dqlC-vU1hA,5568
|
|
4
|
+
hydo_assert/_eventually.py,sha256=9vJnxwLZnmgFbj82ngszGoqoTqMzvjomOUS2P50nsZI,4244
|
|
5
|
+
hydo_assert/_json.py,sha256=bJtpC2QKPEd8wpql5S5eXhfk2fUCoINIV1tKfHbvCjQ,5520
|
|
6
|
+
hydo_assert/_sink.py,sha256=iEboDP_MtFoui1y4zdnCPcpugYBN98YAg4YU8aYgDQg,2536
|
|
7
|
+
hydo_assert/history.py,sha256=z7O_gxpivlpRy0nCYgsqFj76QEHUjYKEA7IA_dd0WY0,10368
|
|
8
|
+
hydo_assert/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
hydo_assert-0.2.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
10
|
+
hydo_assert-0.2.0.dist-info/METADATA,sha256=qnObnT7Ki0Nvb8BDqL5TwsgqmjGOZ_chnDVNh3ngRFs,15367
|
|
11
|
+
hydo_assert-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
12
|
+
hydo_assert-0.2.0.dist-info/top_level.txt,sha256=TvdT9tTbjxBQu9Qv2RRfQrJM8iKJy0eEtM-ptlVZbqY,12
|
|
13
|
+
hydo_assert-0.2.0.dist-info/RECORD,,
|