Release Notes: 0.9.1
Date: 2026-07-29
Status: Stable release
Type: Final pre-v1.0 compatibility reset
Overview
0.9.1 is the final pre-v1.0 baseline-consolidation release. It combines the
0.9.x performance-readiness work with deliberate source-build and public
contract cleanup before the v1.0 compatibility freeze.
v0.9.0 shipped with the intention that it would be the last breaking release
before v1.0. That freeze was deliberately extended through v0.9.1 because v1.0
had not shipped, adoption remained limited, and the final Rust, dependency,
configuration, and ABI audit found cleanup worth completing before the
long-lived contract begins.
Breaking Changes
Rust source-build baseline: 1.91 to 1.97
The repository compiler, release compiler, and public MSRV now share one
baseline:
| Surface | Before | v0.9.1 |
|---|---|---|
| Repository/release compiler | Rust 1.91.1 | Rust 1.97.0 |
| Public Cargo MSRV | Rust 1.91 | Rust 1.97 |
| Debian source-build dependency | rustc >= 1.91 |
rustc >= 1.97 |
For a rustup-managed source-build environment:
rustup toolchain install 1.97.0 --profile minimal \
--component rustfmt,clippy
rustup override set 1.97.0This change affects contributors and users who compile the module from source.
It does not require Rust on systems that install a prebuilt module. Prebuilt
runtime compatibility continues to follow the published NGINX, OS/libc,
architecture, and exact NGINX dynamic-module compatibility matrix.
Streaming configuration: one public selector
markdown_streaming off|auto|force is now the sole public processing-path
selector. The duplicate implementation-level directive is reject-only; there
is no silent alias.
| v0.9.0 configuration | Required v0.9.1 configuration |
|---|---|
markdown_streaming_engine off; |
markdown_streaming off; |
markdown_streaming_engine auto; |
markdown_streaming auto; |
markdown_streaming_engine on; |
markdown_streaming force; |
For example, markdown_streaming_engine on; fails nginx -t with:
"markdown_streaming_engine on" has been removed; use "markdown_streaming force" instead.
Helm users must rename markdown.streaming.engine to
markdown.streaming.mode and use off, auto, or force.
The diagnostics endpoint now reports this selector as
streaming_config.policy (off, auto, or force) and its origin as
streaming_config.policy_source. The superseded engine and engine_source
keys are removed before the v1.0 surface freeze.
Supported Markdown flavors
markdown_flavor now accepts only commonmark and gfm. The experimental
mdx and org-mode selectors never had distinct conversion semantics and now
fail nginx -t with an explicit migration hint.
Bundled Rust/C FFI ABI 1
v0.9.1 establishes ABI version 1 for the internal boundary between the bundled
Rust converter and NGINX C module. The reset removes:
- FFI flavor discriminants 2 (MDX) and 3 (Org-mode);
- the duplicate
FFIStreamingInput.enginefield; FFIConditionalResult, including its reserved, always-zero
matched_etag_lenfield, plusmarkdown_check_conditionaland its init
helper; andmarkdown_build_base_url, superseded by the trusted-proxy-aware
markdown_decide_base_urldecision; and- 15 zero-production-consumer exports covering unused decision/error-policy
wrappers, standalone URL checks, diagnostics-schema accessors, init helpers,
convenience constructors, and redundant streaming finish/free/reason paths.
The production constructors are now markdown_incremental_new_with_code and
markdown_streaming_new_with_code; constructor errors are never hidden behind
a NULL-only wrapper. Runtime streaming eligibility remains owned by the NGINX
module, and the C diagnostics renderer is the only diagnostics schema source.
This is not an operator-callable C SDK migration. Release and source builds
link the Rust archive and C module from one revision. Do not mix an older Rust
archive or generated header with the v0.9.1 C module. NGINX now calls
markdown_abi_version() during preconfiguration and makes nginx -t/startup
fail with an expected/actual version diagnostic when the linked boundary does
not match.
The generated header remains a build input for the bundled module; v0.9.1 does
not introduce an external third-party ABI support promise. ABI 1 is the
baseline intended to freeze with v1.0.
OTel, trusted proxies, and metrics wire cleanup
The implemented experimental OTel surface is now exactly
markdown_otel on|off plus markdown_otel_endpoint <internal-uri>.
markdown_otel_tracing, markdown_otel_metrics,
markdown_otel_service_name, markdown_otel_span_buffer_size, and
markdown_otel_export_timeout fail nginx -t with actionable explanations;
they previously accepted values without distinct production semantics.
markdown_trusted_proxies is now http-only. This matches the process-wide
Rust CIDR handle and prevents unsafe or misleading server/location ownership.
Two Prometheus wire corrections in the final pre-v1 breaking window:
| v0.9.0 surface | v0.9.1 surface |
|---|---|
nginx_markdown_failures_total{reason="memory_budget_exceeded"} for the aggregate resource/timeout bucket |
reason="resource_limit" |
nginx_markdown_failures_total{reason="ffi_panic"} for aggregate system failures |
reason="system_error" |
gauge nginx_markdown_conversion_duration_seconds{le="..."} |
counter nginx_markdown_conversion_latency_bucket_total{le="..."} |
Specific decision logs retain canonical reason codes such as timeout, memory_budget_exceeded, and ffi_panic; only the aggregate Prometheus family uses the broader truthful categories.
The diagnostics JSON no longer emits removed directive-shaped snapshot keys.
Operators roll back dynamic configuration by restoring a prior valid file;
the diagnostics endpoint remains read-only (GET/HEAD).
New Features
Hybrid Zero-Copy Streaming Output
markdown_streaming_zero_copy on (default off, opt-in) enables ngx_buf_t to
reference Rust-owned memory directly without intermediate pool-copy, reducing
memcpy for non-terminal streaming chunks. NGINX pool cleanup handlers ensure
safe Rust buffer lifetime across backpressure and request teardown.
- Terminal chunks and backpressure-active chunks always use pool-copy
- Pool cleanup is the safety net for Rust buffer lifetime
freedflag prevents double-free;catch_unwindwraps Rust deallocation- Togglable via HUP reload without binary rebuild
Gzip and Deflate Streaming Decompression Routing
Under streaming_first profile with markdown_auto_decompress on and
markdown_cache_validation not full, gzip and deflate responses (both
zlib-wrapped RFC 1950 and raw RFC 1951 deflate) are decompressed incrementally
through the streaming engine instead of forcing full-buffer accumulation.
- Gzip member boundaries, trailers, truncation, and response-wide budgets are
preserved across arbitrary upstream chunks and downstream resumes - Pre-commit errors trigger fail-open via replay buffer
- Post-commit errors invoke
markdown_streaming_safe_finishfor graceful
termination (no replay, no status rewrite) - Budget enforcement via
markdown_decompress_max_sizein both states
Full-Buffer Compressed Copy Reduction
Internal optimization (default on, no configuration surface) eliminates
redundant memcpy in the full-buffer compressed path by passing contiguous
buffers directly to the decompressor and swapping output via pointer
assignment. On failure, the original compressed buffer is preserved intact for
fail-open passthrough.
Full-buffer gzip now consumes all concatenated members in both the default Rust
FFI implementation and the C no-Rust fallback. Later-member truncation is
rejected, and all members share the configured decompression budget.
Deflate (zlib-wrapped RFC 1950 or raw RFC 1951) must completely consume its
compressed payload. Trailing bytes after Z_STREAM_END — whether in the same
chunk or in a subsequent non-empty chunk — are rejected as FORMAT_ERROR
rather than silently truncated. Gzip is exempt because it supports
concatenated members. Fail-open behavior is governed by the existing
markdown_error_policy setting.
Brotli Streaming Decompression
Brotli-compressed upstream responses (Content-Encoding: br) now decompress
incrementally through the streaming engine, matching the gzip/deflate streaming
path. Under streaming_first with markdown_auto_decompress on and
markdown_cache_validation not full, Brotli responses are routed to the
streaming decompressor instead of the bounded full-buffer path.
- Strict RFC 7932 single-stream enforcement: trailing data after stream
completion is rejected asFORMAT_ERROR; truncated streams are detected at
EOF - No-progress guard prevents worker busy-loops on malformed streams
- Typed error propagation:
brotli_loop()preserves all
NGX_HTTP_MARKDOWN_DECOMP_*codes without folding to genericNGX_ERROR - Three-way error classification (format/allocation/internal) with frozen
ranges compatible with Brotli 1.0.9+ (Ubuntu 22.04) - Exact-budget completion probes allow streams that produce exactly
markdown_decompress_max_sizebytes to complete without false budget errors - Per-call failure-origin lifecycle enables the outer error mapper to
distinguish allocation failures (ERROR_MEMORY_LIMIT) from internal
failures (ERROR_INTERNAL) - Build dependency: official release builds depend on
libbrotlidec - Configure option:
NGX_MARKDOWN_BROTLI_STREAMING=auto|on|off(default
auto); whenauto, the build system probes for the library and enables
streaming silently if found - No new public NGINX directives or module ABI surface
- Identical budget enforcement, error policy, backpressure model, and
observability semantics as gzip/deflate streaming - Brotli-disabled builds (
NGX_MARKDOWN_BROTLI_STREAMING=off) retain the
existing bounded full-buffer Brotli path via the Rust FFI backend
See ADR 0024: Brotli Streaming Decompression.
markdown_auto_decompress Directive Registration
markdown_auto_decompress is now officially registered as a configurable
NGINX directive (default on). Previously it was an internal field not settable
via nginx.conf — nginx -t would reject user configs using it.
Performance Evidence Gate
Module-level benchmark harness (tools/perf/run_module_benchmark.sh) exercises
the full NGINX request lifecycle with 8 scenarios (plain, chunked, gzip
full-buffer, large body, uncompressed streaming, gzip streaming, deflate
streaming, and Brotli streaming). Automated release gate enforces thresholds before release
promotion:
| Metric | Threshold |
|---|---|
| p50 latency (small) | ≤ +10% vs baseline |
| p95 latency (small) | ≤ +15% vs baseline |
| TTFB (streaming, large) | ≤ +10% vs baseline |
| Streaming fallback rate | ≤ 5% absolute |
| Memory slope (RSS/input_MB) | ≤ +20% vs baseline |
Evidence proves each decompression path independently:
| Scenario | Path proven |
|---|---|
gzip-large |
Full-buffer gzip decompression |
gzip-streaming-first |
Gzip streaming decompression |
deflate-streaming-first |
Deflate streaming decompression |
brotli-streaming-first |
Brotli streaming decompression |
streaming-first |
Uncompressed streaming + zero-copy output |
make perf-evidence-check— non-blocking (report-only, for PRs)make release-gates-check-091— blocking (fails on NO_GO for release tags)
Doctor Advice Tool
python3 tools/perf/doctor_advice.py analyzes runtime metrics and produces
actionable tuning recommendations covering fallback rate, overload,
backpressure, decompression routing, budget exhaustion, pending output
watermark, and zero-copy utilization.
New ADRs
- ADR 0020: Hybrid Zero-Copy Streaming Output with Pool Cleanup
- ADR 0021: Gzip and Deflate Streaming Decompression Routing
- ADR 0022: 0.9.1 Performance Evidence Release Gate
- ADR 0023: Single Public Streaming Policy Before v1.0
- ADR 0024: Brotli Streaming Decompression
Bug Fixes
markdown_auto_decompresswas referenced in 8+ documentation files as a
user-configurable directive but was never registered in thengx_command_t
table —nginx -twould reject user configs using it. Now properly
registered as aNGX_CONF_FLAGdirective.- Streaming backpressure lifecycle: input disposition state machine, pending
output ownership, fail-open lifecycle gaps, and terminal-sent latch semantics
hardened. - Streaming decompression: zlib-wrapped deflate (RFC 1950) header sniffing,
member-aware gzip reset, cumulative budget enforcement, truncated later
member rejection, and native backpressure/output-equivalence coverage.
Malformed streams now increment the format-error counter, incomplete streams
at response EOF increment the truncated-input counter, and true zlib runtime
failures remain classified as I/O errors across pre- and post-commit paths. - Full-buffer gzip: concatenated-member completion, cumulative budget
enforcement, and later-member truncation rejection are consistent across the
default Rust FFI and C fallback implementations. - Performance evidence: the module benchmark harness exercises 8 scenarios
including uncompressed streaming (with zero-copy output), gzip full-buffer
decompression, gzip streaming decompression, deflate streaming
decompression, and Brotli streaming decompression. Before release promotion, the release gate requires
independent per-codecdecompression_streaming_totalor
decompression_fullbuffer_totalevidence from a real module-enabled NGINX. - Security: Forwarded header spoofing prevention (right-most element); workflow
input sanitization via environment variables; base URL FFI scheme propagation
for HTTPS. - Configuration: profile merge preserves explicit parent directives;
max_inflight=0accepted as unlimited; error-policy reject paths return
configurederror_status. - Complexity debt: the remaining C and Rust production/test complexity
hotspots were decomposed, the 27-entry historical complexity baseline was
removed, and the release check now reports zero current and baseline
violations across C, Rust, Python, and shell. - Performance evidence provenance: the canonical 0.9.1 baseline records all
eight native NGINX 1.24.0 scenarios, including Brotli streaming, and binds
verbatim evidence to the exact source commit, workflow run/attempt, UTC
timestamp, and retained raw-artifact digest. The former historical
exception is retained only as an immutable audit record. The regenerated
evidence records the observedplain-smalllatency p50 of 9 ms verbatim;
no metric was normalized to hide a performance change. - Security and release gates: strict repository-contained path validation
closes the baseline finalizer's CodeQL/SonarCloud path findings, and the
native profile E2E helper now honors an explicitMODULE_SOpath.
Upgrade Notes
Source builders must install Rust 1.97.0 or newer before rebuilding. Users of
prebuilt artifacts do not need to install Rust. Before reload, migrate the
streaming directive, Helm value, and removed flavor values as documented
above. Source builders must regenerate the C header and link the Rust archive
from the same revision; the runtime ABI check rejects mixed boundary versions.
The release artifacts remain platform- and NGINX-ABI-specific; select the
artifact that matches the published compatibility matrix.
Verification
# Full test suite
make test-nginx-unit
make test-rust
# Documentation checks
make docs-check
# Complexity and generated FFI-header checks
make complexity-check
make check-headers
# Rust formatting and full converter tests
cargo fmt --manifest-path components/rust-converter/Cargo.toml --all -- --check
cargo test --locked --manifest-path components/rust-converter/Cargo.toml
# Release gate (blocking for release tags)
make release-gates-check-091
# Performance evidence (non-blocking, report-only)
make perf-evidence-check