1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
"""PyO3 panic-contract tests (Pass-2 prototype 7, verified both directions).
Two opposing guarantees for the Rust `csp_solver` native module, exercised
through the real wheel:
1. **Unwind → catchable.** A Rust panic surfaced across the PyO3 boundary
arrives in Python as a `pyo3_runtime.PanicException` — a `BaseException`
(deliberately *not* an `Exception`, so a bare `except Exception:` cannot
silently swallow it), catchable, with the interpreter left usable.
2. **Abort → uncatchable (negative control).** A forced `abort()` (the same
`SIGABRT` a Rust `panic=abort` build or `std::process::abort()` delivers)
is *not* catchable: it terminates the process. Proven under subprocess
isolation so the child's death by signal is observable.
These ride the `py-runtime` CI lane. The unwind test needs the PyO3 wheel,
which lands in W1; until then it is skipped with an explicit, import-tied
reason (never a silent skip). The abort negative control is toolchain-only and
runs unconditionally on POSIX.
"""
"""The Rust native module, or an explicit skip tied to its import failure.
At HEAD the wheel is unbuilt (W1 lands it), so this skips with a reason
naming that dependency rather than failing opaquely.
"""
return
# ── (1) Unwind panic → catchable PanicException ──────────────────────────────
"""A Rust panic must cross the boundary as a catchable PanicException.
Panic path: `Csp.solve()` before `finalize()`. `Csp::solve` (lib.rs) opens
with `self.adjacency.as_ref().expect("call finalize() before solve()")`;
on a fresh `Csp` adjacency is `None`, so the `.expect` panics. This is a
documented-precondition violation reachable purely through the public
Python surface — an `.expect`, not a `debug_assert!`, so it fires in the
release wheel too. (If W1 exposes a dedicated panic hook, prefer it; this
precondition is a stable lib.rs invariant regardless.)
"""
=
=
# pyo3 0.24.2 does NOT register `pyo3_runtime` as an importable
# `sys.modules` entry (`import pyo3_runtime` raises `ModuleNotFoundError`
# even after a panic has fired) — `PanicException.__module__` is the
# string "pyo3_runtime", but that string names no importable module on
# this pyo3 version. Obtain the class from the caught instance instead:
# the contract under test ("catchable, typed, BaseException-not-Exception")
# is fully exercised either way — only the *acquisition* mechanism changes.
= # no finalize() — violates solve()'s precondition
=
assert ==
assert ==
# It is a BaseException but NOT an Exception: a bare `except Exception:`
# must not be able to swallow a Rust panic (the "both directions" contract).
assert
assert not
# Survives: interpreter + module remain usable after the caught panic.
=
assert == 0
# ── (2) Forced abort → uncatchable SIGABRT (negative control) ────────────────
# Child program: force a hard abort and prove Python cannot intercept it. Even
# `except BaseException:` must not shield the process from the signal — reaching
# any print below would refute the contract and fail the parent's assertions.
=
"""A forced abort must kill the process by signal, uncatchable in Python.
The negative control opposite to the unwind test: this is what a Rust
`panic=abort` build or `std::process::abort()` would deliver at the PyO3
boundary — `SIGABRT`, which no Python `try/except` can trap. Run in a child
process so the death-by-signal is observable without taking down the runner.
"""
=
# Killed by SIGABRT: POSIX reports signal death as a negative return code.
assert == -,
# The abort must have been uncatchable: neither branch of the child ran.
assert == b,