Parallel Execution with pytest-xdist¶
RAMPART supports parallel test execution via pytest-xdist, producing a single unified report even when tests run across multiple worker processes.
Quick Start¶
With -n 4, pytest spawns 4 worker processes that execute tests in parallel. RAMPART intercepts each worker's results, ships them to the controller process, and emits one consolidated report at the end of the session.
How It Works¶
Worker 1 Worker 2 Controller
───────── ───────── ──────────
eligible Results eligible Results
│ │
serialize → TestReport serialize → TestReport
│ │
└───────────┬───────────────┘
▼
pytest_runtest_logreport
validate + merge incrementally
│
▼
pytest_testnodedown
reconcile streamed Result counts
│
▼
pytest_sessionfinish (controller)
aggregate trials → evaluate gates → emit sinks
│
▼
Single unified TestRunReport
- Workers attach JSON-safe serialized
Resultobjects to each call-phaseTestReport, or to a non-passing setup report when setup recorded Results and no call report will occur. Workers do not emit RAMPART report sinks. - Controller receives each envelope through
pytest_runtest_logreport, validates and merges it into itsRampartSession, and emits sinks once at session end. - Worker shutdown carries only trial specifications and the expected number of streamed Result representations in
config.workeroutput. The controller reconciles that count inpytest_testnodedown; Results are never delivered through both paths.
The normal transport boundary is the call phase, which includes Results recorded during successful fixture setup. A failed or skipped setup that recorded Results uses its setup report as a fallback because no call report follows. Results recorded during fixture teardown are not streamed.
The result: one JsonFileReportSink output file, one call to MyCustomSink.emit_async, and accurate population statistics over the full result set.
Trial Tests with xdist¶
@pytest.mark.trial(n=, threshold=) clones a test into N independent runs. Under xdist, clones may be distributed across workers depending on the --dist mode.
--dist mode |
Trial behavior |
|---|---|
loadgroup |
All trial clones for one test pinned to the same worker |
load (default) |
Trial clones distributed across all workers |
loadscope / loadfile |
Grouped by class/module/file |
Correctness is preserved regardless of mode — the controller aggregates trial groups from the merged result set and evaluates each group's threshold against the full population. You'll see a warning if you use @trial markers without --dist=loadgroup:
RAMPART @trial markers present with --dist=load. Trial clones may be
split across workers. Aggregation remains correct (controller merges
all results), but using --dist=loadgroup keeps trial clones co-located
on one worker for better locality.
This warning is informational, not a correctness signal — see below for when it's safe to ignore.
Choosing loadgroup vs load¶
Both modes produce an identical, correct report. The controller merges per-worker partials into one population and evaluates each trial's threshold against the full group either way. The choice is about execution, not correctness:
load(default) spreads a test's trial clones across all workers, so a 20-clone trial keeps every worker busy. It is usually the fastest option and is the right default when trial clones are independent (no shared per-group state).loadgrouppins all clones of one trial group to a single worker. Prefer it only when a trial group needs cohesion — e.g. clones share a session-scoped fixture, a per-group cache/connection, or other worker-local state that must not be split across processes. The trade-off is less parallelism, so it can run slower.
Rule of thumb: independent trials → plain pytest -n 4 (faster); trials that
share per-group worker state → pytest -n 4 --dist=loadgroup.
As an illustration, one 22-item suite containing a 20-clone trial measured:
| Mode | Command | Wall time | Reports | total_runs |
|---|---|---|---|---|
| Serial | pytest -n 0 |
203.4s | 1 | 22 |
| Parallel, loadgroup | pytest -n 4 --dist=loadgroup |
165.5s | 1 | 22 |
| Parallel, default load | pytest -n 4 |
113.8s | 1 | 22 |
All three emit the same single report and the same trial verdict; load is fastest
here because the 20 clones fan out across the 4 workers instead of being pinned to one.
Registering Sinks: the pytest_rampart_sinks hook¶
The recommended way to register report sinks is the pytest_rampart_sinks
hook. It is resolved on the controller — which never executes fixtures — so it
behaves identically in single-process and xdist runs, and (unlike the fixture
path) supports sinks that need configuration.
Implement it in your conftest.py:
# conftest.py
from pathlib import Path
from rampart.reporting import JsonFileReportSink
def pytest_rampart_sinks(config):
return [JsonFileReportSink(output_dir=Path(".report"))]
- Multiple implementations are supported; RAMPART emits to the union of every returned sink.
- An implementation may return an empty list to contribute none.
- Non-
ReportSinkitems (or a non-list return) are dropped with a warning, so one malformed implementation cannot break emission.
If your sinks need dependencies, build them inside the hook — it receives the
pytest.Config and runs on the controller, so you can build sinks from config
values or environment variables there.
Trust Boundary & Security¶
Worker payloads cross a process boundary via execnet and may contain attacker-controlled content (agent responses, payload text, evaluator rationale). RAMPART's serialization defends against:
- Arbitrary code execution — strict JSON-safe primitives only; no
pickle,marshal, or custom__reduce__. - Schema drift — payloads with missing or unknown schema versions are rejected fail-closed.
- Memory exhaustion — each serialized Result is capped at 16 MiB by default.
- Terminal/log injection — ANSI escape sequences are stripped from free-form text at the deserialization boundary.
- Path traversal — worker-local artifact paths are stored as opaque strings in metadata; the controller never accesses worker files.
Size cap¶
The default 16 MiB cap can be overridden via the pytest CLI option or an ini setting:
Or in pytest.ini / pyproject.toml:
An oversized Result is replaced by an attributed ERROR/truncation marker while
normal Results from the same worker continue to stream. The controller records
the run as incomplete in TestRunReport.metadata. Configured limits below 4 KiB
use a 4 KiB effective minimum so the marker itself always fits.
Incomplete Runs¶
If a worker crashes, drops a streamed Result, omits its final count, or hits the size cap, the controller marks the run as incomplete:
report.metadata["incomplete"] # True if any worker failed
report.metadata["incomplete_reasons"] # list[str] — one per failure
Reports are still emitted with whatever data was collected. For safety-critical CI, sinks or post-processing should check the incomplete flag and fail the build accordingly.
Run-Mode Metadata¶
Reports produced under xdist include:
report.metadata["xdist_active"] # True
report.metadata["worker_count"] # int
report.metadata["dist_mode"] # "load", "loadgroup", etc.
Durability behavior¶
Each eligible report is merged as it reaches the controller. If a worker is killed mid-run, every Result already delivered remains in the final report and the run is marked incomplete because the worker cannot provide a final expected count. A clean worker shutdown publishes its emitted Result count; any mismatch with the controller's received count detects a silent drop and also marks the run incomplete.
The cap applies independently to each Result. An oversized transcript therefore does not discard normal Results from that worker.
Limitations¶
- Results recorded only during fixture teardown are outside the report-streaming boundary and are not included.
- A worker that dies can lose Results whose eligible reports had not reached the controller; already-streamed Results are retained and the run is marked incomplete (see Durability behavior).
- Mixed RAMPART versions across controller and workers are unsupported; install the same version everywhere.
pytest-xdistitself does not support interactive debugging (--pdb,--trace); use single-process mode for debugging.