Native architecture

View Source

Decision

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.5

Rustler 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:

LayerOwns
Hegel.ExUnitproperty registration, check all syntax, left-to-right dependent bindings, assumption conversion, and default database keys
HegelRun/replay loop, current-case process context, property exception capture, origin selection, minimized replay, diagnostics, and re-raising the original failure
Hegel.GeneratorsElixir values and validation, calls to primitive draws, and shrink structure expressed with spans and collections
Hegel.TestCaseProcess ownership, draw/note recording, control-flow translation, and typed conversion between Elixir values and native primitives
Native Rustler shimTerm encoding, native resources, C allocation guards, handle serialization, output buffering, and result-code translation
hegeltest-c / libhegelChoice 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:

  1. Hegel.Settings.new/1 validates Elixir options. Settings.to_native/1 converts enums and phase/health-check lists to the values defined by the C ABI.
  2. run_start constructs a short-lived C settings handle, applies each setter, starts the libhegel run, and returns a BEAM resource for the run handle.
  3. Elixir calls run_next until completion. libhegel resumes its suspended run loop until it yields a test-case handle or finishes.
  4. 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.
  5. The frontend marks a normal return as valid, Hegel.assume as invalid, and a native choice-budget stop as overrun. It marks an Elixir error, exit, throw, assertion, or Hegel.fail/1 as interesting. Hegel.UsageError and Hegel.NativeError escape the property runner as API or native errors.
  6. 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.
  7. Once complete, run_result copies status, messages, origins, and blobs into BEAM terms and frees the temporary C result allocations.
  8. The frontend replays each minimized blob through the Elixir callback to capture draw values, notes, the original exception, and its stacktrace. Hegel.check/2 reports them and re-raises that failure; Hegel.run/2 returns them in Hegel.Result and Hegel.Failure.
  9. 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/2 transforms a drawn value but retains the underlying choices.
  • bind/2 and composite/2 make 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/3 discards 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:

  • RunResource
  • TestCaseResource
  • StringGeneratorResource
  • CollectionResource
  • PoolResource
  • StateMachineResource

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_OK becomes a normal value or :ok.
  • HEGEL_E_STOP_TEST and HEGEL_E_ASSUME become 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-darwin
  • x86_64-apple-darwin
  • x86_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:

  1. Update the exact hegeltest-c dependency and Cargo lockfile together.
  2. Diff the canonical hegel.h and audit every changed signature, enum, ownership rule, threading rule, and label.
  3. Update the compiled expected-version constant and all Elixir option/value mappings.
  4. Regenerate THIRD_PARTY_LICENSES.html if the Cargo graph changed.
  5. Run lifecycle, shrinking, replay, failure-origin, state-machine, health check, and resource-drop tests on every supported target.
  6. Build new NIF assets and publish a checksum map for the frontend release.
  7. 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.