diff --git a/docs/lanes/tl-i03.md b/docs/lanes/tl-i03.md new file mode 100644 index 0000000..ebc4034 --- /dev/null +++ b/docs/lanes/tl-i03.md @@ -0,0 +1,275 @@ +# tl-i03 — compressed command allocations on WASI + +Status: **implemented and verified on both WASI targets**. Base `3cce439`, branch +`lane/tl-i03`, macOS arm64 (Darwin 25.5.0). The existing MongoDB zero-allocation +gate now passes on `wasm32-wasip2` and `wasm32-wasip3` in all four +compressed/uncompressed × raw/coordinator modes, and it is wired into the +required WASI CI selection. MySQL was checked under its own target allocator, +had exactly the same defect, and is fixed and gated the same way. No dependency +was forked, added, removed or version-bumped; `Cargo.lock` is untouched; no +threshold was relaxed and no test was weakened. + +## Root cause + +`protocols/turnloop-mongodb/src/connection.rs` compressed each `OP_COMPRESSED` +command with one retained `flate2::Compress`, calling `Compress::reset()` before +every message because each MongoDB compressed message is its own RFC 1950 stream. +`reset()` is allocation-free natively and is *not* allocation-free on wasm32: + +- `miniz_oxide-0.9.1/src/deflate/core.rs:1583-1606` stores the LZ code buffer as + `Box<[u8; LZ_CODE_BUF_SIZE]>` on `target_arch = "wasm32"` (an inline array + everywhere else), and its wasm constructor builds it with + `vec![0; LZ_CODE_BUF_SIZE].into_boxed_slice()`. +- `CompressorOxide::reset()` (`core.rs:496`) replaces that buffer wholesale: + `self.lz = LZOxide::new();`. + +So one reset per message is one heap allocation per message on every wasm target +— WASI 0.2, WASI 0.3 and the web — which DESIGN §3 goal 3 / §10.1 forbid. This is +the defect `docs/lanes/proto-fix1.md` recorded as an open release blocker and +`docs/AUDIT_EVIDENCE.md` reproduced as 1,000 allocations for 1,000 commands. +No warm-up can absorb it: the allocation is repeated by every reset. + +`miniz_oxide` 0.9.1 is the newest published version (crates.io index checked: +0.7.2 … 0.9.1), so there is no upstream release to move to. + +## Fix + +New `zlib.rs` module in each affected crate (`protocols/turnloop-mongodb/src/zlib.rs`, +`protocols/turnloop-mysql/src/zlib.rs`). **Nothing resets any more.** The +compressor is created in raw-deflate mode once per connection and kept for its +lifetime; each message is emitted as its own complete zlib stream by writing the +RFC 1950 wrapper directly around a full-flushed deflate segment: + +1. Write the two-byte zlib header `78 9C` (CM 8, CINFO 7, FDICT 0, FCHECK making + the pair a multiple of 31 — RFC 1950 §2.2; FLEVEL is informational and inflate + ignores it). +2. `Compress::compress_vec(input, out, FlushCompress::Full)`. flate2's documented + contract for a full flush is that all pending output is written, the stream is + left on a byte boundary, and the compression state is reset "so that + decompression can restart from this point" — upstream `flate2` asserts exactly + that in `src/mem.rs::test_full_flush`, and `miniz_oxide` implements it by + emitting an empty stored block (`core.rs:1892`) and clearing the match + dictionary and hash chains (`core.rs:2520`). +3. Append the two-byte final empty fixed-Huffman block `03 00` (BFINAL 1, + BTYPE 01, end-of-block symbol). It is valid precisely because step 2 left the + stream byte-aligned, and it is what makes the stream terminate for an + `inflate`/`uncompress` caller. +4. Append the big-endian Adler-32 of the uncompressed message (RFC 1950 §9), + computed by a small allocation-free helper. + +Completion is proved rather than assumed: the helper checks the returned +`Status`, that `total_in` advanced by exactly `input.len()`, and that spare +capacity remained after the flush (`compress_vec` writes only into spare +capacity, so leftover capacity is the zlib idiom for "avail_out was never +exhausted", and it also holds the six-byte trailer). Callers reserve through +`bound(len)`, which is comfortably above zlib's own `deflateBound`, so the whole +append is allocation-free once the output buffer has warmed. + +Cost: about six bytes per message versus a `Finish`-terminated stream (the +five-byte full-flush marker plus the two-byte terminator, less the padding a +`Finish` would have needed). Compression ratio is unchanged within a message; +cross-message history was never used, because every message was an independent +stream before this change too. + +MySQL's compressed framing (`protocols/turnloop-mysql/src/codec.rs`) had the +identical `encoder.reset()`-per-frame shape and now uses the same helper; the +`encoded` scratch buffer is cleared and reserved instead of `resize`d, and the +"send it plain if compression did not help" decision now compares the produced +stream length instead of the (no longer per-message) `total_out`. + +### Why not the alternatives + +- **Fork `miniz_oxide`.** Avoided, as the item asks. It would have to be packaged + for downstream consumers the way `turnloop-zstd-decoder` was, for a one-line + upstream defect. +- **Switch flate2 to the `zlib-rs` backend.** Would add a large new + unsafe-heavy dependency to every target and every crate that uses flate2, and + restart the seven-day soak, to fix one reset path. +- **Accept the allocation on wasm only.** Rejected: DESIGN requires the budget in + every configuration, and a per-target exception is exactly the untested + configuration the repository's gate rules warn about. + +## Evidence + +All numbers are from this clone. "before" is `git checkout 3cce439 -- +protocols/turnloop-mongodb protocols/turnloop-mysql` in the fixed tree, i.e. the +unchanged base sources under the same toolchains and the same wasmtime. + +### The gate, per target (allocations over 1,000 measured commands) + +| Suite | Target / profile | before | after | +| --- | --- | ---: | ---: | +| `turnloop-mongodb --test allocations` | native aarch64-apple-darwin, debug | 0 | 0 | +| `turnloop-mongodb --test allocations` | `wasm32-wasip2`, debug (CI `wasi-tests`) | **1000** (FAIL) | **0** (PASS) | +| `turnloop-mongodb --test allocations` | `wasm32-wasip2`, release | **1000** (FAIL) | **0** (PASS) | +| `turnloop-mongodb --test allocations` | `wasm32-wasip3`, release (CI `wasi-p3-release-tests`) | **1000** (FAIL) | **0** (PASS) | +| `turnloop-mysql --test allocations` | native aarch64-apple-darwin, debug | 0 | 0 | +| `turnloop-mysql --test allocations` | `wasm32-wasip2`, debug | **1000** (FAIL) | **0** (PASS) | +| `turnloop-mysql --test allocations` | `wasm32-wasip2`, release | **1000** (FAIL) | **0** (PASS) | +| `turnloop-mysql --test allocations` | `wasm32-wasip3`, release | **1000** (FAIL) | **0** (PASS) | + +Before the fix the first compressed assertion aborted the wasm process, so the +compressed *coordinator* mode never ran at all on either target; it now runs. + +### All four MongoDB modes, both WASI targets + +Per-mode lines are printed by the gate itself (they are new; the counts they +print are asserted, not merely displayed). `wasm32-wasip2` debug and +`wasm32-wasip3` release both produced exactly: + +``` +zlib=false operation=false: 1000 measured commands, 2000 measured rows, 0 allocations +zlib=false operation=true: 1000 measured commands, 2000 measured rows, 0 allocations +zlib=true operation=false: 1000 measured commands, 2000 measured rows, 0 allocations +zlib=true operation=true: 1000 measured commands, 2000 measured rows, 0 allocations +``` + +plus, in the same binary, the positive allocator calibration +(`allocator_counts_alloc_zeroed_and_realloc_on_the_measured_thread`, which proves +alloc/alloc_zeroed/realloc each count exactly one on the measuring thread). The +allocator itself is untouched: no counting was disabled, scoped away or moved off +the measured thread. + +MySQL prints, on both targets: + +``` +compression=false: 1000 measured pings, 0 allocations +compression=false: 1000 measured queries, 1010 rows, 0 allocations +compression=true: 1000 measured pings, 0 allocations +compression=true: 1000 measured queries, 1010 rows, 0 allocations +``` + +### The gate cannot pass without its subject + +Two additions make "green" mean the compressed path actually ran: + +- `tests/allocations.rs` now asserts the outgoing opcode inside the measured + loop — `OP_COMPRESSED` when `zlib=true`, `OP_MSG` when not — and asserts the + executed command count (1,002 per mode: two warm-ups plus 1,000 measured). +- `tests/protocol.rs::compressed_commands_are_independent_zlib_streams` runs 64 + consecutive commands on one connection and decodes each one with a **fresh** + `flate2::read::ZlibDecoder` that never saw the previous message, then asserts + the decompressed bytes equal, byte for byte, the `OP_MSG` an uncompressed + connection produces for the same command, that the declared uncompressed size + matches, and that all 64 frames were genuinely smaller than their input. + +For MySQL, `codec.rs::consecutive_compressed_frames_decode_with_the_upstream_codec` +encodes 32 consecutive compressed frames and decodes them with +`mysql_common`'s *independent* compressed codec, which is what a real server +would do. + +`src/zlib.rs` carries its own unit tests in both crates: RFC 1950 Adler-32 +vectors (including a two-window input), 64 consecutive messages each decoded by a +fresh decompressor, a steady-state loop over incompressible input asserting the +output `Vec` never reallocates and never exceeds `bound`, and empty/one-byte +messages. + +## CI wiring + +`protocols/turnloop-mongodb/Cargo.toml` and `protocols/turnloop-mysql/Cargo.toml`: + +```toml +wasi-tests = ["asynchronous", "allocations"] +wasi-p3-release-tests = ["asynchronous", "allocations"] +``` + +This is the same shape `turnloop-postgres` already uses, and it is the selection +`scripts/ci/run-tests.py` reads: the `protocol-wasi` suite runs every +`wasi-tests` target of every `protocol`/`codec`/`adapter` member, in release on +`wasm32-wasip3` for the targets also listed in `wasi-p3-release-tests`. +`checked_tests` fails the job on a non-zero exit and on a zero passed-test count, +so the gate can neither be skipped nor pass vacuously. Verified by running the +real runner, not by reading it — see the command ledger. + +## MySQL finding + +**MySQL has the same defect.** `protocols/turnloop-mysql/src/codec.rs` called +`encoder.reset()` once per compressed frame, and its own allocation gate +(`turnloop-mysql --test allocations`, which measures 1,000 compressed pings and +1,000 compressed 512-byte-row queries under the crate's own target allocator) +reported **1,000 allocations, expected 0** on `wasm32-wasip2` and +`wasm32-wasip3` — for the compressed configuration only, exactly like MongoDB. +That suite was **not** part of any WASI CI selection before this lane, which is +why it had never gone red. It is fixed with the same helper and added to both +WASI selections. + +`turnloop-http` also depends on flate2 but only ever *decompresses* +(`flate2::Decompress`); `Decompress::reset` does not allocate on wasm (the 1,000 +observed allocations correspond one-to-one with compressor resets while the +decompressor was resetting on the same iterations), so no other crate is +affected. No other `flate2::Compress` user exists in the workspace. + +## Verification + +Toolchains: pinned `nightly-2026-08-20` (default), `nightly-2026-09-07` for +`wasm32-wasip3`, stable 1.98.0. WASI runs use the checksum-verified wasi-sdk 34.0 +installed by `scripts/ci/install-wasm-toolchain.py` (SHA-256 re-verified in this +clone) and wasmtime **46.0.0**, the version `scripts/ci/tools.json` pins. + +| Command | Result | +| --- | --- | +| `cargo fmt --all --check` | PASS | +| `cargo clippy --locked --workspace --all-targets -- -D warnings -D clippy::undocumented_unsafe_blocks` | PASS | +| Same, `--all-features` | PASS | +| Same, `--all-features --target wasm32-wasip2`, debug and release | PASS, PASS | +| Same, `--all-features --target wasm32-wasip3` (nightly-2026-09-07), debug and release | PASS, PASS | +| Same, `--all-features --target wasm32-unknown-unknown` | PASS | +| `cargo doc --locked --workspace --all-features --no-deps` with `RUSTDOCFLAGS=-D warnings` | PASS | +| `cargo +stable check --locked --workspace --all-targets --all-features` (stable is 1.97.1 here, i.e. the declared MSRV) | PASS | +| `cargo +1.98.0 check --locked --workspace --all-targets --all-features` | PASS | +| `cargo test --locked --workspace -- --test-threads=1` | PASS: **294 passed**, 13 ignored, 0 failed (64 groups) | +| `cargo test --locked --workspace --all-features -- --test-threads=1` | PASS: **361 passed**, 20 ignored, 0 failed (80 groups) | +| `python3 scripts/ci/run-tests.py wasi --target wasm32-wasip2` | PASS: 88 tests | +| `python3 scripts/ci/run-tests.py wasi --target wasm32-wasip3` | PASS: 91 tests | +| `python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip2` | PASS: 92 tests over 21 suites, **including the newly selected `turnloop-mongodb --test allocations` and `turnloop-mysql --test allocations`** | +| `python3 scripts/ci/run-tests.py protocol-wasi --target wasm32-wasip3` | PASS: 92 tests over 21 suites, same two new selections in release | +| `python3 scripts/test-servers.py --services mongodb run python3 scripts/ci/run-tests.py protocol --package turnloop-mongodb` | PASS: `real_mongodb` and `async_server`; the real-server case reports "standalone, 3-member replica set, SCRAM SHA-1/SHA-256/default, TLS, **zlib**, CRUD, cursors, transactions, retry identity and change stream events" — a live mongod accepted every new compressed command | +| `python3 scripts/test-servers.py --services mysql run python3 scripts/ci/run-tests.py protocol --package turnloop-mysql` | PASS: `server` (3, including `auth_prepared_transactions_compression_and_infile`) and `async_server`; a live mysqld accepted the new compressed frames | +| `bash scripts/ci/no-tokio.sh` | PASS: eight targets plus the union, default and all features | +| `python3 scripts/ci/soak.py` | PASS: 251 locked registry versions; only the pre-existing rustls exception; seven-day resolver policy still active | +| `python3 scripts/ci/check-paths.py` | PASS: 1492 tracked files | +| `python3 scripts/ci/feature_modes.py` | PASS: 6 public features, 18 required native CI arms | +| `python3 -m unittest discover -s scripts/ci -p 'test_*.py'` | PASS: 98 tests | +| `git diff --check` | PASS | +| Baseline reruns of the four WASI gate configurations on unmodified `3cce439` sources | FAIL as expected, 1000 allocations each — see the table above | +| Browser/Node runtime | **UNRUN.** Neither MongoDB nor MySQL has a web test target, so there is nothing to execute there; `wasm32-unknown-unknown` is covered by strict Clippy only. The defect and the fix are both keyed on `target_arch = "wasm32"`, which includes the web target. | +| Linux and Windows native runtime | **UNRUN** (no machine here). Both are pure-Rust paths with no platform code; the integrator's matrix covers them. | + +## Deviations, dead ends and notes + +- The first design considered was leaving `reset()` in place and forking + `miniz_oxide` to retain its boxed LZ buffer. It was dropped: the item prefers no + fork, and a fork would have to be published for downstream consumers. + Swapping flate2 to its `zlib-rs` backend was also dropped — it adds a large new + dependency to every crate and target and restarts the soak, to fix one reset. +- `FlushCompress::Full` was chosen over `Sync` deliberately: `Sync` flushes and + byte-aligns but does **not** clear the match dictionary, so the following + message would back-reference the previous one and would not decode standalone. + The protocol/upstream-decoder tests above exist to keep that distinction honest. +- `mysql_common`'s `PacketCodec` decodes our frames in `codec.rs`'s new test, and + the `turnloop-mysql` allocation suite already decoded the client's frames with + it; that independent implementation is why the MySQL change is not resting on + our own decompressor agreeing with our own compressor. +- `.tools/` holds this lane's logs (`.tools/tl-i03/*.log`), the wasi-sdk 34.0 that + `scripts/ci/install-wasm-toolchain.py` verified by SHA-256 in this clone, and a + symlink to a wasmtime 46.0.0 binary extracted from an archive whose SHA-256 + matches `scripts/ci/tools.json` exactly + (`ab4bdab6ea42a3245cda91cdc6e0430491c4b78ecd643406fc1764ccddbdcd25`). `.tools/` + is git-ignored; nothing under it is committed. +- No `Cargo.lock`, dependency, feature, soak-policy, workflow or DESIGN change was + made. The only manifest edits are the two `wasi-tests` / `wasi-p3-release-tests` + lines. + +## For the integrator + +These audit rows are now false and can be updated when the audit is refreshed; +this lane deliberately did not edit the audit documents: + +- `docs/REMAINING_WORK.md` I03. +- `docs/AUDIT_EVIDENCE.md:104` "WASI 0.2 compressed MongoDB: FAIL, subject ran". +- `docs/AUDIT_MARKERS.md:119` ("MongoDB compressed allocations are omitted by the + WASI manifest allowlist") and `:145`. +- `docs/DESIGN_AUDIT.md:34`, `:137` and `:150` where they cite the compressed + WASI MongoDB failure and the unselected `Cargo.toml:41` allowlist. +- `docs/INTEGRATION_REPORT.md:110`. +- `docs/lanes/proto-fix1.md`'s "Open release blocker" and its next-step 2. diff --git a/protocols/turnloop-mongodb/Cargo.toml b/protocols/turnloop-mongodb/Cargo.toml index cc06d71..844c01e 100644 --- a/protocols/turnloop-mongodb/Cargo.toml +++ b/protocols/turnloop-mongodb/Cargo.toml @@ -38,9 +38,9 @@ workspace = true [package.metadata.turnloop-ci] role = "protocol" -wasi-tests = ["asynchronous"] +wasi-tests = ["asynchronous", "allocations"] # Pinned p3 compiler: custom allocator traps before main in debug (wasm2 report). -wasi-p3-release-tests = ["asynchronous"] +wasi-p3-release-tests = ["asynchronous", "allocations"] wasi-integration-tests = ["async_server"] wasi-setup-tests = ["real_mongodb"] integration-tests = ["real_mongodb", "async_server"] diff --git a/protocols/turnloop-mongodb/src/connection.rs b/protocols/turnloop-mongodb/src/connection.rs index 5fb251a..36d8be2 100644 --- a/protocols/turnloop-mongodb/src/connection.rs +++ b/protocols/turnloop-mongodb/src/connection.rs @@ -64,7 +64,8 @@ impl Connection { decoder: Decoder::new(wire::DEFAULT_MAX_MESSAGE), expanded: Vec::with_capacity(8192), compressed: Vec::with_capacity(8192), - compressor: flate2::Compress::new(flate2::Compression::default(), true), + // Raw deflate: zlib.rs writes the RFC 1950 wrapper per message. + compressor: flate2::Compress::new(flate2::Compression::default(), false), decompressor: flate2::Decompress::new(true), zlib: false, events: VecDeque::with_capacity(8), @@ -175,20 +176,9 @@ impl Connection { self.compressed .extend_from_slice(&((self.tx.len() - 16) as i32).to_le_bytes()); self.compressed.push(2); - self.compressor.reset(); - self.compressed - .reserve(self.tx.len() + self.tx.len() / 100 + 256); - let status = self - .compressor - .compress_vec( - &self.tx[16..], - &mut self.compressed, - flate2::FlushCompress::Finish, - ) - .map_err(|_| Error::protocol("Compression failed"))?; - if status != flate2::Status::StreamEnd { - return Err(Error::protocol("Compression buffer exhausted")); - } + // One retained deflate state, one zlib stream per message; see zlib.rs. + crate::zlib::append_stream(&mut self.compressor, &self.tx[16..], &mut self.compressed) + .map_err(Error::protocol)?; let n = self.compressed.len() as i32; self.compressed[..4].copy_from_slice(&n.to_le_bytes()); std::mem::swap(&mut self.tx, &mut self.compressed); diff --git a/protocols/turnloop-mongodb/src/lib.rs b/protocols/turnloop-mongodb/src/lib.rs index 40cf83a..78e3d2d 100644 --- a/protocols/turnloop-mongodb/src/lib.rs +++ b/protocols/turnloop-mongodb/src/lib.rs @@ -29,6 +29,7 @@ pub mod topology; pub use time::Instant; pub mod uri; pub mod wire; +mod zlib; pub use connection::{Connection, ConnectionEvent}; pub use error::{Error, ErrorKind, Result}; diff --git a/protocols/turnloop-mongodb/src/zlib.rs b/protocols/turnloop-mongodb/src/zlib.rs new file mode 100644 index 0000000..4007690 --- /dev/null +++ b/protocols/turnloop-mongodb/src/zlib.rs @@ -0,0 +1,165 @@ +//! One retained deflate state per connection, one independent zlib stream per +//! message, and no allocation after the first. +//! +//! `OP_COMPRESSED` payloads are separate RFC 1950 streams, which normally means +//! `flate2::Compress::reset` between messages. That is free natively and is not +//! free on wasm32: miniz_oxide keeps the LZ code buffer on the heap there +//! (`deflate/core.rs`, `LZOxide::new`) and `CompressorOxide::reset` replaces it +//! wholesale, so one reset per message is one allocation per message on WASI and +//! on the web. DESIGN §3.3 forbids per-operation allocation in every +//! configuration, so nothing here resets. +//! +//! Each message is flushed with `FlushCompress::Full` instead. Its documented +//! contract writes every pending byte, ends on a byte boundary and clears the +//! match dictionary, so the bytes that follow are decodable by a decompressor +//! that starts there. The RFC 1950 wrapper is then written directly: the +//! two-byte header ahead of the deflate data, and behind it a final empty +//! fixed-Huffman block — valid precisely because the full flush left the stream +//! byte-aligned — followed by the big-endian Adler-32 of the message. The result +//! is a complete, independently decompressible stream per message, while the +//! compressor keeps its storage for the life of the connection. +use flate2::{Compress, FlushCompress, Status}; + +/// CM 8 (deflate) and CINFO 7 (32 KiB window), FLEVEL 2, with FCHECK chosen so +/// the pair is a multiple of 31 as RFC 1950 § 2.2 requires. +const HEADER: [u8; 2] = [0x78, 0x9c]; +/// BFINAL 1 and BTYPE 01 (fixed Huffman) followed by the seven-bit end-of-block +/// symbol: the shortest terminator for an already byte-aligned deflate stream. +const FINAL_BLOCK: [u8; 2] = [0x03, 0x00]; +/// The four-byte Adler-32 plus `FINAL_BLOCK`. +const TRAILER: usize = FINAL_BLOCK.len() + 4; + +/// Bytes [`append_stream`] can append for `len` input bytes. Comfortably above +/// zlib's own `deflateBound` (`len + len/4096 + len/16384 + 13`, the stored-block +/// worst case) plus the two-byte header, the five-byte full-flush marker and the +/// trailer, so a complete flush always leaves unused capacity behind. +fn bound(len: usize) -> usize { + len + len / 1000 + 64 +} + +/// Appends one complete zlib stream of `input` to `out`, reusing `compressor`'s +/// storage. Allocates only if `out` lacks [`bound`] spare bytes. +pub(crate) fn append_stream( + compressor: &mut Compress, + input: &[u8], + out: &mut Vec, +) -> Result<(), &'static str> { + out.reserve(bound(input.len())); + out.extend_from_slice(&HEADER); + let consumed = compressor.total_in(); + let start = out.len(); + let status = compressor + .compress_vec(input, out, FlushCompress::Full) + .map_err(|_| "Compression failed")?; + if status != Status::Ok || compressor.total_in() - consumed != input.len() as u64 { + return Err("Compression did not consume the message"); + } + // compress_vec writes into spare capacity only. Capacity left over proves the + // flush was not cut short by a full output buffer, and holds the trailer. + if out.capacity() - out.len() < TRAILER { + return Err("Compression buffer exhausted"); + } + debug_assert!(out.len() - start <= bound(input.len()) - HEADER.len() - TRAILER); + out.extend_from_slice(&FINAL_BLOCK); + out.extend_from_slice(&adler32(input).to_be_bytes()); + Ok(()) +} + +/// RFC 1950 § 9. 5552 is the longest run that cannot overflow before reduction. +fn adler32(input: &[u8]) -> u32 { + const BASE: u32 = 65521; + let (mut low, mut high) = (1_u32, 0_u32); + for chunk in input.chunks(5552) { + for &byte in chunk { + low += u32::from(byte); + high += low; + } + low %= BASE; + high %= BASE; + } + (high << 16) | low +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::{Compression, Decompress, FlushDecompress}; + + fn inflate(stream: &[u8], expected: usize) -> Vec { + // A fresh decompressor per message: exactly what the server does. + let mut decoder = Decompress::new(true); + let mut out = vec![0; expected + 1]; + let status = decoder + .decompress(stream, &mut out, FlushDecompress::Finish) + .expect("fixture operation must succeed"); + assert_eq!(status, Status::StreamEnd, "stream must terminate"); + assert_eq!(decoder.total_in(), stream.len() as u64, "trailing bytes"); + out.truncate(decoder.total_out() as usize); + out + } + + #[test] + fn adler32_matches_rfc1950_examples() { + assert_eq!(adler32(b""), 1); + assert_eq!(adler32(b"a"), 0x0062_0062); + assert_eq!(adler32(b"Wikipedia"), 0x11E6_0398); + // Two full reduction windows, so the chunked accumulation is exercised. + assert_eq!(adler32(&[0xff; 11_104]), 0xFF6F_3726); + } + + #[test] + fn consecutive_messages_decompress_independently() { + let mut compressor = Compress::new(Compression::default(), false); + let mut out = Vec::new(); + let mut decompressed = 0; + for round in 0..64_u32 { + let mut message = Vec::new(); + for index in 0..300_u32 { + message.extend_from_slice(&(round * index).to_le_bytes()); + } + out.clear(); + append_stream(&mut compressor, &message, &mut out) + .expect("fixture operation must succeed"); + assert!(out.len() < message.len(), "round {round} must compress"); + assert_eq!(inflate(&out, message.len()), message, "round {round}"); + decompressed += 1; + } + assert_eq!(decompressed, 64); + } + + #[test] + fn steady_state_appends_without_allocating_or_exceeding_the_bound() { + let mut compressor = Compress::new(Compression::default(), false); + // Incompressible input is deflate's worst case for the bound. + let mut message = Vec::new(); + let mut state = 0x2545_F491_4F6C_DD1D_u64; + for _ in 0..8192 { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + message.extend_from_slice(&state.to_le_bytes()); + } + let mut out = Vec::with_capacity(bound(message.len())); + let capacity = out.capacity(); + for round in 0..16 { + out.clear(); + append_stream(&mut compressor, &message, &mut out) + .expect("fixture operation must succeed"); + assert_eq!(out.capacity(), capacity, "round {round} reallocated"); + assert!(out.len() <= bound(message.len()), "round {round} bound"); + assert_eq!(inflate(&out, message.len()), message, "round {round}"); + } + } + + #[test] + fn empty_and_single_byte_messages_round_trip() { + let mut compressor = Compress::new(Compression::default(), false); + let mut out = Vec::new(); + for message in [b"".as_slice(), b"x".as_slice(), &[0_u8; 1]] { + out.clear(); + append_stream(&mut compressor, message, &mut out) + .expect("fixture operation must succeed"); + assert_eq!(inflate(&out, message.len().max(1)), message); + } + } +} diff --git a/protocols/turnloop-mongodb/tests/allocations.rs b/protocols/turnloop-mongodb/tests/allocations.rs index 6660172..56ba454 100644 --- a/protocols/turnloop-mongodb/tests/allocations.rs +++ b/protocols/turnloop-mongodb/tests/allocations.rs @@ -63,6 +63,7 @@ fn measure(compressed: bool, coordinator: bool) { use turnloop_mongodb::operation::*; let mut operation = Operation::new(); let mut rows = 0; + let mut commands = 0; let mut run = |round| { cmd.find("db", "items", &filter, None).unwrap(); if coordinator { @@ -100,6 +101,16 @@ fn measure(compressed: bool, coordinator: bool) { c.command(round, cmd.raw(), &[], now).unwrap(); } let req = wire::i32_at(c.transmit(), 4).unwrap(); + // The measured subject must be the compressed encoder in compressed mode. + assert_eq!( + wire::i32_at(c.transmit(), 12).unwrap(), + if compressed { + wire::OP_COMPRESSED + } else { + wire::OP_MSG + } + ); + commands += 1; let n = c.transmit().len(); c.consume_transmit(n).unwrap(); wire::encode(&mut reply, 77, req, 0, &result, &[], 10000).unwrap(); @@ -136,6 +147,13 @@ fn measure(compressed: bool, coordinator: bool) { } }); assert_eq!(rows, 2004); + assert_eq!( + commands, 1002, + "two warm-ups plus every measured command ran" + ); + println!( + "zlib={compressed} operation={coordinator}: 1000 measured commands, 2000 measured rows, {allocations} allocations" + ); assert_eq!( allocations, 0, "1000 warmed commands/2000 rows allocated {allocations} times (zlib={compressed}, operation={coordinator})" diff --git a/protocols/turnloop-mongodb/tests/protocol.rs b/protocols/turnloop-mongodb/tests/protocol.rs index 4f16e0d..ec47658 100644 --- a/protocols/turnloop-mongodb/tests/protocol.rs +++ b/protocols/turnloop-mongodb/tests/protocol.rs @@ -777,3 +777,93 @@ fn discarded_lease_preserves_other_connections_and_generation() { "stale lease rejected" ); } +/// One retained deflate state must still emit one independently decodable RFC 1950 +/// stream per command (`src/zlib.rs`). Every round is decoded by a fresh upstream +/// decoder that never saw the previous message, exactly as a server does. +#[test] +fn compressed_commands_are_independent_zlib_streams() { + use std::io::Read; + let mut compressed = + Connection::new(Options::parse("mongodb://localhost/?compressors=zlib").unwrap()); + let mut plain = ready(); + compressed.connected(clock::now(), "").unwrap(); + let req = Message::parse(compressed.transmit(), wire::DEFAULT_MAX_MESSAGE) + .unwrap() + .request_id; + let n = compressed.transmit().len(); + compressed.consume_transmit(n).unwrap(); + let mut hello = Vec::new(); + wire::encode( + &mut hello, + 2, + req, + 0, + &raw(&doc! {"ok":1,"maxWireVersion":27,"compression":["zlib"]}), + &[], + wire::DEFAULT_MAX_MESSAGE, + ) + .unwrap(); + feed(&mut compressed, &hello); + assert!(matches!( + compressed.poll_event(), + Some(ConnectionEvent::Ready) + )); + let mut decoded_rounds = 0; + let mut shrank = 0; + for round in 0..64_u64 { + // Repetitive filters make real deflate output smaller than the input. + let body = raw(&doc! { + "find":"items","$db":"db","filter":{"tag":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + "comment":format!("round {round} aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + }); + compressed.command(round, &body, &[], clock::now()).unwrap(); + plain.command(round, &body, &[], clock::now()).unwrap(); + let framed = compressed.transmit().to_vec(); + let expected = plain.transmit().to_vec(); + assert_eq!(wire::i32_at(&framed, 12).unwrap(), wire::OP_COMPRESSED); + assert_eq!(wire::i32_at(&framed, 0).unwrap() as usize, framed.len()); + assert_eq!(wire::i32_at(&framed, 16).unwrap(), wire::OP_MSG); + let size = wire::i32_at(&framed, 20).unwrap() as usize; + assert_eq!(size, expected.len() - 16); + assert_eq!(framed[24], 2, "zlib compressor id"); + if framed.len() < expected.len() { + shrank += 1; + } + let mut body_out = Vec::new(); + let read = flate2::read::ZlibDecoder::new(&framed[25..]) + .read_to_end(&mut body_out) + .unwrap(); + assert_eq!(read, size, "round {round} declared size"); + // The decompressed payload must be the exact OP_MSG an uncompressed + // connection sends, header excluded. + assert_eq!(body_out, expected[16..], "round {round} payload"); + decoded_rounds += 1; + for (c, reply_to) in [ + (&mut compressed, wire::i32_at(&framed, 4).unwrap()), + (&mut plain, wire::i32_at(&expected, 4).unwrap()), + ] { + let n = c.transmit().len(); + c.consume_transmit(n).unwrap(); + let mut reply = Vec::new(); + wire::encode( + &mut reply, + 900, + reply_to, + 0, + &raw(&doc! {"ok":1}), + &[], + wire::DEFAULT_MAX_MESSAGE, + ) + .unwrap(); + feed(c, &reply); + assert!(matches!( + c.poll_event(), + Some(ConnectionEvent::Reply { token }) if token == round + )); + assert_eq!(c.reply().unwrap().get_i32("ok").unwrap(), 1); + c.release_reply().unwrap(); + } + } + assert_eq!(decoded_rounds, 64, "every round must be decoded"); + assert_eq!(shrank, 64, "every round must actually compress"); +} diff --git a/protocols/turnloop-mysql/Cargo.toml b/protocols/turnloop-mysql/Cargo.toml index 711a2e0..ab14de2 100644 --- a/protocols/turnloop-mysql/Cargo.toml +++ b/protocols/turnloop-mysql/Cargo.toml @@ -30,9 +30,9 @@ workspace = true [package.metadata.turnloop-ci] role = "protocol" -wasi-tests = ["asynchronous"] +wasi-tests = ["asynchronous", "allocations"] # Pinned p3 compiler: custom allocator traps before main in debug (wasm2 report). -wasi-p3-release-tests = ["asynchronous"] +wasi-p3-release-tests = ["asynchronous", "allocations"] wasi-integration-tests = ["async_server"] integration-tests = ["server", "async_server"] diff --git a/protocols/turnloop-mysql/README.md b/protocols/turnloop-mysql/README.md index 94ef850..3a4491f 100644 --- a/protocols/turnloop-mysql/README.md +++ b/protocols/turnloop-mysql/README.md @@ -84,8 +84,11 @@ terminates input. To reject an enabled request safely, abort the connection. Compression negotiates CLIENT_COMPRESS. Plain and compressed framing handle 24-bit lengths, sequence wrapping, continuation and exact-boundary empty frames. -The outer zlib encoder/decoder states are retained and reset; upstream's -per-frame zlib construction did not satisfy this lane's allocation contract. +The outer zlib encoder/decoder states are retained for the connection; upstream's +per-frame zlib construction did not satisfy this lane's allocation contract. The +encoder is never reset either — each frame is a full-flushed segment wrapped in +its own RFC 1950 header, final block and Adler-32 (`src/zlib.rs`), because +resetting a deflate state reallocates its LZ buffer on wasm32. Zstd and MariaDB extensions are out of scope. ## mysql2 conversions and hooks diff --git a/protocols/turnloop-mysql/src/codec.rs b/protocols/turnloop-mysql/src/codec.rs index 9247aa4..1ddfa7d 100644 --- a/protocols/turnloop-mysql/src/codec.rs +++ b/protocols/turnloop-mysql/src/codec.rs @@ -1,8 +1,9 @@ //! Reusable plain/compressed MySQL framing. mysql_common's compressed codec -//! creates a zlib encoder/decoder per frame; this one resets retained states. +//! creates a zlib encoder/decoder per frame; this one retains both for the +//! connection and never resets the encoder (see `zlib.rs`). use crate::{Error, Result}; use bytes::{Buf, BytesMut}; -use flate2::{Compress, Compression, Decompress, FlushCompress, FlushDecompress, Status}; +use flate2::{Compress, Compression, Decompress, FlushDecompress, Status}; const CHUNK: usize = 0xff_ffff; fn length(b: &[u8]) -> usize { b[0] as usize | ((b[1] as usize) << 8) | ((b[2] as usize) << 16) @@ -47,7 +48,8 @@ impl PacketCodec { output: BytesMut::with_capacity(8192), encoded: Vec::new(), decoded: Vec::new(), - encoder: Compress::new(level, true), + // Raw deflate: zlib.rs writes the RFC 1950 wrapper per frame. + encoder: Compress::new(level, false), decoder: Decompress::new(true), })); self.seq = 0; @@ -60,18 +62,12 @@ impl PacketCodec { c.output.clear(); encode_plain(&mut self.seq, src, &mut c.output); for chunk in c.output.chunks(CHUNK) { - c.encoder.reset(); - c.encoded.resize(chunk.len() + chunk.len() / 1000 + 128, 0); - let status = c - .encoder - .compress(chunk, &mut c.encoded, FlushCompress::Finish) - .map_err(|_| Error::Protocol("compression failed"))?; - if status != Status::StreamEnd || c.encoder.total_in() != chunk.len() as u64 { - return Err(Error::Protocol("incomplete compression")); - } - let size = c.encoder.total_out() as usize; - let (payload, plain) = if size < chunk.len() { - (&c.encoded[..size], chunk.len()) + // One retained deflate state, one zlib stream per frame; see zlib.rs. + c.encoded.clear(); + crate::zlib::append_stream(&mut c.encoder, chunk, &mut c.encoded) + .map_err(Error::Protocol)?; + let (payload, plain) = if c.encoded.len() < chunk.len() { + (&c.encoded[..], chunk.len()) } else { (chunk, 0) }; @@ -218,6 +214,29 @@ mod tests { decoder.compress(Compression::fast()); assert!(decoder.decode(&mut bad, &mut Vec::new()).is_err()); } + /// One retained deflate state must still emit one independently decodable + /// RFC 1950 stream per frame (`src/zlib.rs`). mysql_common's compressed codec + /// is an independent implementation and never sees our encoder's state. + #[test] + fn consecutive_compressed_frames_decode_with_the_upstream_codec() { + let mut encoder = PacketCodec::default(); + encoder.compress(Compression::fast()); + let mut upstream = mysql_common::proto::codec::PacketCodec::default(); + upstream.compress(Compression::fast()); + let mut decoded = 0; + for round in 0..32_u8 { + let body = vec![b'a' + round % 26; 4096]; + let mut bytes = BytesMut::new(); + encoder.encode(&mut &body[..], &mut bytes).unwrap(); + assert!(bytes.len() < body.len() / 4, "round {round} must compress"); + let mut output = Vec::new(); + assert!(upstream.decode(&mut bytes, &mut output).unwrap()); + assert_eq!(output, body, "round {round}"); + assert!(bytes.is_empty(), "round {round} left trailing bytes"); + decoded += 1; + } + assert_eq!(decoded, 32); + } #[test] fn full_fragment_requires_empty_terminator() { let body = vec![7; CHUNK]; diff --git a/protocols/turnloop-mysql/src/lib.rs b/protocols/turnloop-mysql/src/lib.rs index 9400729..4e3d869 100644 --- a/protocols/turnloop-mysql/src/lib.rs +++ b/protocols/turnloop-mysql/src/lib.rs @@ -31,6 +31,7 @@ pub use mysql_common::{ use std::fmt; mod codec; mod wire; +mod zlib; use codec::PacketCodec; pub mod pool; pub mod types; diff --git a/protocols/turnloop-mysql/src/zlib.rs b/protocols/turnloop-mysql/src/zlib.rs new file mode 100644 index 0000000..5c8f25c --- /dev/null +++ b/protocols/turnloop-mysql/src/zlib.rs @@ -0,0 +1,165 @@ +//! One retained deflate state per connection, one independent zlib stream per +//! compressed packet, and no allocation after the first. +//! +//! MySQL's compressed protocol frames each carry their own RFC 1950 stream, +//! which normally means `flate2::Compress::reset` between frames. That is free +//! natively and is not free on wasm32: miniz_oxide keeps the LZ code buffer on +//! the heap there (`deflate/core.rs`, `LZOxide::new`) and +//! `CompressorOxide::reset` replaces it wholesale, so one reset per frame is one +//! allocation per frame on WASI and on the web. DESIGN §3.3 forbids +//! per-operation allocation in every configuration, so nothing here resets. +//! +//! Each message is flushed with `FlushCompress::Full` instead. Its documented +//! contract writes every pending byte, ends on a byte boundary and clears the +//! match dictionary, so the bytes that follow are decodable by a decompressor +//! that starts there. The RFC 1950 wrapper is then written directly: the +//! two-byte header ahead of the deflate data, and behind it a final empty +//! fixed-Huffman block — valid precisely because the full flush left the stream +//! byte-aligned — followed by the big-endian Adler-32 of the frame. The result +//! is a complete, independently decompressible stream per frame, while the +//! compressor keeps its storage for the life of the connection. +use flate2::{Compress, FlushCompress, Status}; + +/// CM 8 (deflate) and CINFO 7 (32 KiB window), FLEVEL 2, with FCHECK chosen so +/// the pair is a multiple of 31 as RFC 1950 § 2.2 requires. +const HEADER: [u8; 2] = [0x78, 0x9c]; +/// BFINAL 1 and BTYPE 01 (fixed Huffman) followed by the seven-bit end-of-block +/// symbol: the shortest terminator for an already byte-aligned deflate stream. +const FINAL_BLOCK: [u8; 2] = [0x03, 0x00]; +/// The four-byte Adler-32 plus `FINAL_BLOCK`. +const TRAILER: usize = FINAL_BLOCK.len() + 4; + +/// Bytes [`append_stream`] can append for `len` input bytes. Comfortably above +/// zlib's own `deflateBound` (`len + len/4096 + len/16384 + 13`, the stored-block +/// worst case) plus the two-byte header, the five-byte full-flush marker and the +/// trailer, so a complete flush always leaves unused capacity behind. +fn bound(len: usize) -> usize { + len + len / 1000 + 64 +} + +/// Appends one complete zlib stream of `input` to `out`, reusing `compressor`'s +/// storage. Allocates only if `out` lacks [`bound`] spare bytes. +pub(crate) fn append_stream( + compressor: &mut Compress, + input: &[u8], + out: &mut Vec, +) -> Result<(), &'static str> { + out.reserve(bound(input.len())); + out.extend_from_slice(&HEADER); + let consumed = compressor.total_in(); + let start = out.len(); + let status = compressor + .compress_vec(input, out, FlushCompress::Full) + .map_err(|_| "Compression failed")?; + if status != Status::Ok || compressor.total_in() - consumed != input.len() as u64 { + return Err("Compression did not consume the message"); + } + // compress_vec writes into spare capacity only. Capacity left over proves the + // flush was not cut short by a full output buffer, and holds the trailer. + if out.capacity() - out.len() < TRAILER { + return Err("Compression buffer exhausted"); + } + debug_assert!(out.len() - start <= bound(input.len()) - HEADER.len() - TRAILER); + out.extend_from_slice(&FINAL_BLOCK); + out.extend_from_slice(&adler32(input).to_be_bytes()); + Ok(()) +} + +/// RFC 1950 § 9. 5552 is the longest run that cannot overflow before reduction. +fn adler32(input: &[u8]) -> u32 { + const BASE: u32 = 65521; + let (mut low, mut high) = (1_u32, 0_u32); + for chunk in input.chunks(5552) { + for &byte in chunk { + low += u32::from(byte); + high += low; + } + low %= BASE; + high %= BASE; + } + (high << 16) | low +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::{Compression, Decompress, FlushDecompress}; + + fn inflate(stream: &[u8], expected: usize) -> Vec { + // A fresh decompressor per message: exactly what the server does. + let mut decoder = Decompress::new(true); + let mut out = vec![0; expected + 1]; + let status = decoder + .decompress(stream, &mut out, FlushDecompress::Finish) + .expect("fixture operation must succeed"); + assert_eq!(status, Status::StreamEnd, "stream must terminate"); + assert_eq!(decoder.total_in(), stream.len() as u64, "trailing bytes"); + out.truncate(decoder.total_out() as usize); + out + } + + #[test] + fn adler32_matches_rfc1950_examples() { + assert_eq!(adler32(b""), 1); + assert_eq!(adler32(b"a"), 0x0062_0062); + assert_eq!(adler32(b"Wikipedia"), 0x11E6_0398); + // Two full reduction windows, so the chunked accumulation is exercised. + assert_eq!(adler32(&[0xff; 11_104]), 0xFF6F_3726); + } + + #[test] + fn consecutive_messages_decompress_independently() { + let mut compressor = Compress::new(Compression::default(), false); + let mut out = Vec::new(); + let mut decompressed = 0; + for round in 0..64_u32 { + let mut message = Vec::new(); + for index in 0..300_u32 { + message.extend_from_slice(&(round * index).to_le_bytes()); + } + out.clear(); + append_stream(&mut compressor, &message, &mut out) + .expect("fixture operation must succeed"); + assert!(out.len() < message.len(), "round {round} must compress"); + assert_eq!(inflate(&out, message.len()), message, "round {round}"); + decompressed += 1; + } + assert_eq!(decompressed, 64); + } + + #[test] + fn steady_state_appends_without_allocating_or_exceeding_the_bound() { + let mut compressor = Compress::new(Compression::default(), false); + // Incompressible input is deflate's worst case for the bound. + let mut message = Vec::new(); + let mut state = 0x2545_F491_4F6C_DD1D_u64; + for _ in 0..8192 { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + message.extend_from_slice(&state.to_le_bytes()); + } + let mut out = Vec::with_capacity(bound(message.len())); + let capacity = out.capacity(); + for round in 0..16 { + out.clear(); + append_stream(&mut compressor, &message, &mut out) + .expect("fixture operation must succeed"); + assert_eq!(out.capacity(), capacity, "round {round} reallocated"); + assert!(out.len() <= bound(message.len()), "round {round} bound"); + assert_eq!(inflate(&out, message.len()), message, "round {round}"); + } + } + + #[test] + fn empty_and_single_byte_messages_round_trip() { + let mut compressor = Compress::new(Compression::default(), false); + let mut out = Vec::new(); + for message in [b"".as_slice(), b"x".as_slice(), &[0_u8; 1]] { + out.clear(); + append_stream(&mut compressor, message, &mut out) + .expect("fixture operation must succeed"); + assert_eq!(inflate(&out, message.len().max(1)), message); + } + } +} diff --git a/protocols/turnloop-mysql/tests/allocations.rs b/protocols/turnloop-mysql/tests/allocations.rs index bff476e..a24b7b9 100644 --- a/protocols/turnloop-mysql/tests/allocations.rs +++ b/protocols/turnloop-mysql/tests/allocations.rs @@ -114,6 +114,7 @@ fn warm_ping_and_compressed_ping_allocate_nothing() { run(); } }); + println!("compression={compression}: 1000 measured pings, {count} allocations"); assert_eq!(count, 0, "compression={compression}"); assert_eq!(complete, 1010); // Rows use metadata and a compressed 512-byte value from the independent @@ -196,6 +197,9 @@ fn warm_ping_and_compressed_ping_allocate_nothing() { run(); } }); + println!( + "compression={compression}: 1000 measured queries, {rows} rows, {count} allocations" + ); assert_eq!(count, 0, "row compression={compression}"); assert_eq!(rows, 1010); assert_eq!(complete, 1010);