Native architecture
View SourceDecision
Hegel for Elixir uses a small Rustler NIF to bind the canonical libhegel C ABI.
Cargo pins hegeltest-c = "=0.32.5"; the NIF calls its hegel_* functions and
links the engine's Rust rlib into hegel_native. Rustler 0.38.0 handles BEAM
terms and resource ownership. RustlerPrecompiled downloads checksum-verified
GitHub Release artifacts for supported targets. The frontend does not depend
on the high-level Rust hegeltest library.
ExUnit property / Hegel.check
|
v
Elixir run loop + Hegel.Generators
|
Rustler NIF calls
v
Rustler terms + ResourceArc ownership
|
canonical hegel_* API
v
linked hegeltest-c/libhegel 0.32.5Rustler manages terms and BEAM resources. Direct calls to the C ABI define the frontend's property-testing semantics. No high-level Rust property wrapper sits between Elixir and libhegel.
The header
hegel-c/include/hegel.h,
defines the binding contract. The published
libhegel reference describes the same
calling, ownership, lifecycle, replay, and threading contracts.
Alternatives considered
High-level hegeltest through Rustler
The Rust frontend accepts Rust test closures, panic/failure machinery, and generator types. Those types do not fit the Elixir boundary:
- Elixir must execute the property so ExUnit assertions, catches, process-local state, stacktraces, and source locations keep their Elixir behavior.
- Calling an Elixir closure back from a NIF-controlled Rust run loop would introduce scheduler re-entry and environment-lifetime constraints. The BEAM run loop avoids those constraints.
- Converting Elixir generator terms into the high-level Rust generator graph would duplicate a frontend inside the NIF and make dependent Elixir control flow awkward. The C ABI lets a language frontend request each draw while its property executes.
- Hegel groups distinct bugs by origin. The frontend derives that origin from the Elixir stacktrace; Rust panic machinery has no Elixir source location.
- Depending on a language-specific wrapper adds API surface without adding engine capability. Database reuse, targeting, collections, state machines, and reproduce blobs all exist at the C ABI.
The high-level wrapper would save little native code and obscure Elixir failure semantics, so this frontend omits it.
A C NIF using erl_nif.h
A C NIF could call a released libhegel shared library with one fewer source
language. Its authors would still need to implement BEAM resources, reference
counts, mutexes, double-close protection, term decoding, binary allocation,
dirty scheduling, callback lifetimes, and error-path cleanup.
Rustler provides those mechanisms with RAII and typed encoding. A small Rust
shim lets Cargo pin and link hegeltest-c without a platform loader. The shim
confines unsafe to calls across the C contract. A contract violation can still
crash the VM; Rustler removes much of the handwritten lifetime and allocation
code where such defects occur.
A separate prebuilt libhegel
A downloaded libhegel.so, .dylib, or .dll would cut compile
time while adding search-path, code-signing, checksum, platform-matrix, and
ABI-skew problems. Intel macOS and unpublished targets would also need another
installation path. Static inclusion produces one target-specific runtime NIF
and gives Hegel.version/0 one linked engine to report. Each target must compile
the Rust source and rebuild the NIF after an engine upgrade.
Boundary responsibilities
Each layer owns a defined part of the run:
| Layer | Owns |
|---|---|
Hegel.ExUnit | property registration, check all syntax, left-to-right dependent bindings, assumption conversion, and default database keys |
Hegel | Run/replay loop, current-case process context, property exception capture, origin selection, minimized replay, diagnostics, and re-raising the original failure |
Hegel.Generators | Elixir values and validation, calls to primitive draws, and shrink structure expressed with spans and collections |
Hegel.TestCase | Process ownership, draw/note recording, control-flow translation, and typed conversion between Elixir values and native primitives |
| Native Rustler shim | Term encoding, native resources, C allocation guards, handle serialization, output buffering, and result-code translation |
hegeltest-c / libhegel | Choice generation, example reuse, targeting, health checks, state-machine scheduling, shrinking, database persistence, and reproduce blobs |
Elixir runs all user property logic. Rustler resources keep raw native pointers inside the NIF.
Run lifecycle
During a Hegel.check/2 or Hegel.run/2 call:
Hegel.Settings.new/1validates Elixir options.Settings.to_native/1converts enums and phase/health-check lists to the values defined by the C ABI.run_startconstructs a short-lived C settings handle, applies each setter, starts the libhegel run, and returns a BEAM resource for the run handle.- Elixir calls
run_nextuntil completion. libhegel resumes its suspended run loop until it yields a test-case handle or finishes. - The Elixir property runs in its owning BEAM process. Each generator invokes one or more short native draw calls. Compound generators surround their choices with the C span and collection primitives.
- The frontend marks a normal return as
valid,Hegel.assumeasinvalid, and a native choice-budget stop asoverrun. It marks an Elixir error, exit, throw, assertion, orHegel.fail/1asinteresting.Hegel.UsageErrorandHegel.NativeErrorescape the property runner as API or native errors. - libhegel generates, targets, and shrinks. For an ExUnit assertion, the frontend uses the first user frame inside the property. If tail-call optimization removed the anonymous property frame, it combines the property module and source with the assertion AST line. Other failures use the first user frame above the runner boundary and fall back to the property identity. libhegel uses this stable origin to group distinct bugs; final replay rejects a failure if its origin changed.
- Once complete,
run_resultcopies status, messages, origins, and blobs into BEAM terms and frees the temporary C result allocations. - The frontend replays each minimized blob through the Elixir callback to
capture draw values, notes, the original exception, and its stacktrace.
Hegel.check/2reports them and re-raises that failure;Hegel.run/2returns them inHegel.ResultandHegel.Failure. - The runner closes handles on normal and error paths. Rust resource destructors cover process death and abandoned resources.
libhegel owns a language-neutral choice sequence. It cannot retain Elixir terms or exceptions. The frontend replays the minimized choices to produce Elixir diagnostics.
With :reproduce or HEGEL_REPRODUCE, the frontend skips the normal run loop
and result. hegel_test_case_from_blob creates one case, the property executes
it, and the frontend verifies that it fails. A pass, assumption, or overrun
produces a reproduction error.
Generator protocol and shrinking
Hegel.Generator stores a function from the current Hegel.TestCase to an
Elixir value. It stores no RNG and does not implement Enumerable:
- Primitive generators ask libhegel for a choice with explicit constraints.
map/2transforms a drawn value but retains the underlying choices.bind/2andcomposite/2make dependent draws through Elixir control flow.- Fixed shapes use spans; variable-length shapes use engine-managed collections. The frontend reports rejected unique keys or elements to the collection so they do not consume its size.
filter/3discards a structural span and retries within that span before it rejects the example. The native filter health check can then diagnose a narrow domain.- State-machine steps use their reserved structural label. The shrinker can remove or simplify actions while keeping a valid choice sequence.
libhegel's shrinker transforms a recorded sequence of structured decisions. The Elixir property then interprets the sequence again. The frontend has no lazy shrink tree or global generation-size input.
libhegel's C settings enable multiple-failure reporting. The Elixir frontend
sets it to false by default, matching StreamData's first-failure workflow and
Hegel's higher-level Rust and TypeScript frontends. Set
report_multiple_failures: true to collect failures from distinct origins.
Ownership and cleanup
Rustler wraps each caller-owned C handle in a distinct resource:
RunResourceTestCaseResourceStringGeneratorResourceCollectionResourcePoolResourceStateMachineResource
Test-case, string-generator, collection, pool, and state-machine resources
store Mutex<Option<RawHandle<T>>>. The mutex serializes native use and the
Option makes close a one-way state transition. Explicit close takes the
pointer and invokes its matching C destructor; Drop performs the same take
as a fallback. This one-way take prevents double free. ResourceArc keeps the
resource alive throughout a NIF call if its Elixir owner exits during the call.
RunResource owns a command channel to one persistent native worker. The
worker alone owns the C run handle. Both explicit run_close and
RunResource::drop call RunWorker::shutdown; shutdown sends Close, waits for
the worker to release the run, and joins the worker thread before it returns.
Rustler holds the resource during active NIF calls, so Drop reaches an idle
command loop. The synchronous join prevents a worker from executing unloaded
NIF code after a hot-code purge.
Rust guards own short-lived C allocations for settings, run results, failure
copies, byte results, and string results. Their Drop implementations call the
matching *_free operation on success and error paths. The NIF copies borrowed
C strings into Rust strings before freeing their parent result or context.
Each NIF operation creates and frees its own mutable CallContext. It reads the
diagnostic before the next ABI call can overwrite that context's error buffer.
Hegel.TestCase records its owner PID and rejects access from another process.
The C ABI supports cloned streams, but this frontend leaves them unexposed
because BEAM scheduling would make choice order unstable and break replay. Draw
immutable values in the property process before starting Tasks.
Scheduling
hegel_next_test_case can do significant engine work between yields during
targeting and shrinking. On macOS, its terminal failure/shrink continuation
also needs more stack than a BEAM scheduler thread supplies. Polling it from
that thread caused a reproducible SIGBUS at the end of a failing run.
RunWorker::start creates one worker with an 8 MiB stack for each run and
reuses it until close. The worker serializes next, result creation,
cancellation, and run destruction. Rustler marks run_next, run_result, and
run_close as DirtyIo; each NIF sends a command and waits for the worker's
reply. The worker performs the engine work without occupying a dirty CPU
scheduler or creating a thread for each generated and shrink example.
run_next returns a test-case resource before Elixir calls the property. User
property code therefore runs outside the NIF on its BEAM process.
Bounded scalar draws, settings operations, marking, and destructors remain normal NIF calls. Rustler marks big-integer and byte generation, string-spec construction, and string generation as dirty CPU work because their cost grows with user-controlled input. Future bindings must assign a suitable scheduler to any C operation that can block or search for an unbounded period.
Resource mutexes serialize native access; they do not permit sharing a live case. One Elixir process drives a run, and the owner check enforces that public contract.
Output and errors
libhegel writes output through a native callback. The callback runs on the run
worker, or on the calling thread for a
standalone blob case. It copies each UTF-8 line into an
Arc<Mutex<Vec<String>>> and does not call Elixir. Run and test-case resources
share the buffer across yielded cases.
Hegel.Result.output receives the drained buffer, and final replay copies its
lines onto each failure. Hegel.check/2 renders engine lines with minimized
draws, notes, and the reproduce command. Programmatic callers can read the same
output without exposing BEAM IO to a native callback.
The NIF maps C results as follows:
HEGEL_OKbecomes a normal value or:ok.HEGEL_E_STOP_TESTandHEGEL_E_ASSUMEbecome internal control flow and are marked as overrun or invalid, not user failures.- Backend, handle, argument, lifecycle, internal, and concurrent-use errors
become tagged tuples and then
Hegel.NativeError, or a run-level%Hegel.Result{status: :error}when libhegel completed without a verdict.
The NIF load callback calls hegel_version and compares it with the compiled
constant 0.32.5. A mismatch stops NIF loading. After that check,
Hegel.version/0 reports the linked engine version.
Static linking and distribution
Cargo builds hegel_native as a cdylib and consumes hegeltest-c as an
rlib. Each NIF contains libhegel, so the runtime never loads a second engine
shared object and has no HEGEL_LIBHEGEL_PATH setting.
RustlerPrecompiled selects NIF ABI 2.15 and supports three release targets:
aarch64-apple-darwinx86_64-apple-darwinx86_64-unknown-linux-gnu
The release workflow builds each target with Rust 1.92.0, packages the NIF, generates SHA-256 checksums, and uploads the assets to the matching GitHub Release. The Hex package contains the checksum map. A consuming project downloads and verifies one archive during dependency compilation.
The upstream hegeltest-c package also declares a cdylib, so a source build
may leave an unused hegel_c artifact beside the NIF. hegel_native links the
engine into its own binary and does not load that artifact. The Cargo lockfile
fixes the native dependency graph; the consuming project records Hex
dependencies in its Mix lockfile.
Targets outside the release matrix require Rust 1.91 or newer and a native
toolchain. Users add Rustler to the consuming project and set
HEGEL_ELIXIR_BUILD=1 to compile from source. The Hex package includes the
native source and Cargo files for this path.
Each release archive and the Hex source package carry
THIRD_PARTY_NOTICES.md, the cargo-about report in
THIRD_PARTY_LICENSES.html, and the Rust standard-library notices in
RUST_STDLIB_COPYRIGHT.html.
Version policy and upgrades
Hegel's beta policy allows a 0.N.0 engine release to break compatibility and
reserves patch releases for compatible changes. The Elixir frontend also has
its own 0.x API. It pins the engine version because:
- the C ABI may change across beta minor releases;
- Hegel guarantees reproduce-blob compatibility only within a version;
- enum values, ownership rules, and output behavior are part of the adapter's safety case; and
- a range dependency could change the native engine without a matching Elixir code change.
For an engine upgrade:
- Update the exact
hegeltest-cdependency and Cargo lockfile together. - Diff the canonical
hegel.hand audit every changed signature, enum, ownership rule, threading rule, and label. - Update the compiled expected-version constant and all Elixir option/value mappings.
- Regenerate
THIRD_PARTY_LICENSES.htmlif the Cargo graph changed. - Run lifecycle, shrinking, replay, failure-origin, state-machine, health check, and resource-drop tests on every supported target.
- Build new NIF assets and publish a checksum map for the frontend release.
- Treat old reproduce blobs as stale unless the new version accepts them, and document any frontend migration before releasing.
Keep the exact pin. It forms part of the boundary contract.
Known tradeoffs
- Forced source builds are slower and less portable than a pure-Elixir dependency.
- A NIF defect can affect the whole VM. Rustler and RAII reduce memory-lifetime risk but cannot provide process isolation.
- Garbage collection of an abandoned live run waits for the idle worker to cancel and exit. Normal completion closes and joins the worker before GC.
- Static linking increases the artifact size and requires a rebuild for each engine update.
- The process-owned test-case policy leaves libhegel's cloned concurrent streams unexposed. A future concurrency API would need ordered semantics to preserve replay on the BEAM.
- The public API exposes generators and state machines. Variable-pool resources remain internal until a stateful abstraction needs them.