Skip to content

v0.9.1

Latest

Choose a tag to compare

@github-actions github-actions released this 29 Jul 20:02
· 1005 commits to main since this release
409e3e0

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

This 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.engine field;
  • FFIConditionalResult, including its reserved, always-zero
    matched_etag_len field, plus markdown_check_conditional and its init
    helper; and
  • markdown_build_base_url, superseded by the trusted-proxy-aware
    markdown_decide_base_url decision; 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
  • freed flag prevents double-free; catch_unwind wraps 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_finish for graceful
    termination (no replay, no status rewrite)
  • Budget enforcement via markdown_decompress_max_size in 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 as FORMAT_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 generic NGX_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_size bytes 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); when auto, 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.confnginx -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

Bug Fixes

  • markdown_auto_decompress was referenced in 8+ documentation files as a
    user-configurable directive but was never registered in the ngx_command_t
    table — nginx -t would reject user configs using it. Now properly
    registered as a NGX_CONF_FLAG directive.
  • 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-codec decompression_streaming_total or
    decompression_fullbuffer_total evidence 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=0 accepted as unlimited; error-policy reject paths return
    configured error_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 observed plain-small latency 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 explicit MODULE_SO path.

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