Skip to content

blackbull.client.scenario_oracle

blackbull.client.scenario_oracle

Differential oracle shared by the conformance test and atheris fuzz.

Sprint 18 Phase 3 — the categoriser, per-side outcome dataclass, and scenario runner used to live inside tests/conformance/http1/test_http1_differential.py. That kept them out of the production package (correct for test-only code), but it also meant the atheris fuzz harness at tests/conformance/http1/fuzz/fuzz_http1.py couldn't reuse them without importing the pytest module (which side-effects on import via pytest.importorskip and pytest.skip).

This module is the smallest extracted surface that lets atheris drive the same differential check as the Hypothesis test:

  • :class:Category — the 9-way enum that buckets each example.
  • :data:ACCEPTED_CATEGORIES — pass-through set (OK, BOTH_REJECTED); divergences sit outside.
  • :class:SideOutcome — what one server returned (response, or exception / timeout) in normalised form.
  • :func:normalize_response, :func:categorize — the pure functions consumed by both callers.
  • :func:run_scenario — the async wrapper that drives a :class:~blackbull.client.Scenario against (host, port) and returns (SideOutcome, wire_bytes).
  • :data:PER_REQUEST_TIMEOUT_S — wall budget cap per side.

The pytest module continues to own everything test-specific — fixtures, Hypothesis strategies, corpus capture, DiffContext — since none of that is meaningful in the atheris context.

Category

Bases: str, Enum

Why a differential example was (not) accepted.

Subclassing str makes the values JSON-serialisable directly and keeps assert ctx.category == 'OK'-style sites readable.

SideOutcome dataclass

One side's response in a differential pair.

Exactly one of (response, exception) is populated. timed_out is True if the failure was an :class:asyncio.TimeoutError from the per-example wait_for; we record it separately because timeouts are semantically distinct from other transport errors.

categorize(ng, bb)

Bucket a differential example into a :class:Category.

Order of the checks matters: both-rejected wins over individual transport failures so we don't flag inputs that nginx also refused.

normalize_response(resp)

Drop volatile / framing-only headers; preserve status + body.

run_scenario(host, port, scenario) async

Execute scenario against (host, port) and return (outcome, wire_bytes).

Drives the scenario through :meth:HTTP1Client.execute_scenario, which itself never raises; this wrapper only adds an outer asyncio.wait_for so a runaway scenario (e.g. trickled bytes plus a long read timeout) can't blow the per-side budget.

Returns the captured wire bytes (both servers receive identical bytes, so a single capture is enough for failure diagnostics).