ADR-001 · Optional Rust extension
Why stream operations, why optional, and the fallback strategy.
Python remains the behavioural reference. Rust accelerates the byte copy between pipeline stages when the extension is available — nothing else.
01 — What is optional
Cuprum is complete without Rust. The extension speeds up one operation, and says so when it declines.
Cuprum’s streams normally move through asyncio in 64 KiB reads. For a few kilobytes that costs nothing worth measuring. For gigabytes flowing through a multi-stage pipeline, every chunk is a trip through the event loop and a fresh bytes object, and the extension exists to take that copy off the loop.
It is a separate compiled module that Cuprum imports if it can. A
pure-Python install behaves identically, reports
is_rust_available() as False, and never
needs a Rust toolchain.
02 — Capabilities
Available, integrated, implemented-but-inert, and proposed are different states, and the matrix keeps them apart.
| Capability | Python path | Rust extension | Notes |
|---|---|---|---|
| Inter-stage pipeline pumping | Available | Integrated | Used under auto or rust when the extension imports. splice() on Linux, a read/write loop on macOS. Each hop that cannot be handed over safely runs on the Python pump instead. |
| Pumping on Windows | Available | Python only | Asyncio’s Proactor pipes use overlapped handles the synchronous native pump cannot safely use, so every hop declines with platform_unsupported. A raw overlapped handle handed straight to the native shim is rejected and recorded as windows_raw_handle_rejected rather than used. |
| Capturing stdout and stderr | Available | Implemented, not integrated | rust_consume_stream exists and is tested, and tests fail if production code routes through it. Integration waits on a measured 20% wall-time win (roadmap phase 6). |
| Echo and tee to sinks | Available | Proposed | A raw-sink helper for qualifying sinks, to be profiled before it is built (roadmap phase 7). |
| Line callbacks and line events | Available | Python only | Decoding and per-line dispatch stay in Python whichever backend is selected. |
| Spawning and orchestrating processes | Available | Under investigation | A synchronous native subprocess path and native pipeline orchestration are open decisions (roadmap step 8.3). Process management is Python today. |
| Native wheels | Available | Available | Published with 0.2.0b2 for glibc-based Linux (x86_64, aarch64), macOS (x86_64, arm64), and Windows (x86_64), one cp312-abi3 wheel per platform for CPython 3.12 and later. Other platforms and free-threaded builds install the pure-Python wheel; Windows arm64 is planned (roadmap 8.5.1). |
03 — Backend selection
One environment variable, read once per process.
CUPRUM_STREAM_BACKEND takes three values.
auto, the default, uses Rust when the extension
imports and Python otherwise. python forces the pure
path, for debugging or to reproduce a pure-Python install.
rust requires the extension and fails loudly without
it, for benchmarking.
The choice is resolved on first use and cached for the life of the process, so set it before Cuprum runs anything. Changing it later has no effect.
$ CUPRUM_STREAM_BACKEND=python python my_script.py
$ CUPRUM_STREAM_BACKEND=rust python my_script.py
import cuprum
print(cuprum.is_rust_available())
True
False
A hop that falls back is a routing decision, not an error, so it is
logged at DEBUG on cuprum._pipeline_streams
with a cuprum_reason: raw_fd_unavailable,
reader_unresumable, reader_pause_failed,
blocking_mode_unavailable,
duplicate_fds_unavailable, or
platform_unsupported. To count them instead, register
PumpMetricsHook with observe_pump; its
labels are drawn from that closed set, so the series count is fixed.
04 — Parity and limits
The stream tests run on both backends and assert identical output for empty streams, multibyte UTF-8 split across chunk boundaries, downstream stages that exit early, and backpressure under large payloads. Hypothesis generates random payloads and chunk boundaries and checks every byte survives either pump.
The unsafe Rust is confined to audited boundary crates, with Verus,
Kani, and Miri evidence archived, and the native pump’s lifecycle
is modelled with Loom. Since 0.2.0-beta1, Miri runs across more of
the stream boundary, and a RustStreamError enum at
the PyO3 boundary maps every native failure to the same Python
exception the pure path would raise, so both backends fail the
same way.
No speed-up figure is quoted here. The users’ guide describes multi-fold throughput gains on large pipe-to-pipe transfers and negligible differences on small outputs, but a number without its workload, machine, baseline, and sample size would be decoration. CI benchmarks every push to main and every pull request that touches performance-relevant code, and gates on the Rust-to-Python ratio; published measurements with that context are pending.
To measure your own workload, run make benchmark-e2e
in a checkout. It covers three payload sizes, two pipeline depths,
and runs with and without line callbacks, on each available
backend.
Why stream operations, why optional, and the fallback strategy.
The consume and raw-sink proposals, and the evidence each must produce.
Tried the native path? Report what you measured, or what declined, on the issue tracker.