Skip to main content

wacore_binary/
zlib_pool.rs

1use std::cell::RefCell;
2use std::io;
3use zlib_rs::{Inflate, InflateError, InflateFlush, Status};
4
5/// zlib inflate wants a zlib header and the 32 KB LZ77 window.
6const ZLIB_HEADER: bool = true;
7const WINDOW_BITS: u8 = 15;
8
9thread_local! {
10    static DECOMPRESSOR: RefCell<(Inflate, Vec<u8>)> = RefCell::new((
11        Inflate::new(ZLIB_HEADER, WINDOW_BITS),
12        Vec::with_capacity(4096),
13    ));
14
15    // Free-list of streaming-reader state (inflate state ~48 KB + 64 KB buf). A
16    // connection's bootstrap history sync decompresses several blobs sequentially,
17    // each via a fresh `InflateReader`; reusing the state avoids re-initializing
18    // zlib and re-allocating the buffer per blob.
19    static INFLATE_POOL: RefCell<Vec<(Inflate, Vec<u8>)>> = const { RefCell::new(Vec::new()) };
20}
21
22/// Inflate straight into the vector's spare capacity, then extend its length by
23/// the produced count. Unlike `flate2::Decompress::decompress_vec`, this never
24/// zero-initializes the spare region first: flate2's zlib-rs backend doesn't
25/// override `decompress_uninit`, so it memsets the whole output window before
26/// every call — pure waste, since inflate overwrites exactly those bytes.
27fn inflate_into_spare(
28    inflate: &mut Inflate,
29    input: &[u8],
30    out: &mut Vec<u8>,
31    flush: InflateFlush,
32) -> Result<Status, InflateError> {
33    let before = inflate.total_out();
34    let status = inflate.decompress_uninit(input, out.spare_capacity_mut(), flush)?;
35    let produced = (inflate.total_out() - before) as usize;
36    // SAFETY: `decompress_uninit` wrote exactly `produced` bytes (per total_out)
37    // into the spare capacity, so that prefix is now initialized and in-bounds.
38    unsafe { out.set_len(out.len() + produced) };
39    Ok(status)
40}
41
42/// Streaming zlib reader: decompresses `input` incrementally into a small
43/// accumulation buffer, so a caller can parse length-delimited records as they
44/// become available and discard consumed bytes — peak memory stays ~the largest
45/// single record being buffered, not the whole decompressed blob.
46///
47/// Usage: `ensure(n)` to make ≥ n bytes available, read from `available()`, then
48/// `consume(k)`. The buffer is compacted (consumed prefix dropped) as it grows.
49pub struct InflateReader<'a> {
50    input: &'a [u8],
51    in_pos: usize,
52    // `Option` so `Drop` can move the state back into the pool (Inflate has no
53    // cheap throwaway value to swap in). Always `Some` until dropped.
54    decomp: Option<Inflate>,
55    buf: Vec<u8>,
56    cursor: usize,
57    total_out: u64,
58    max: u64,
59    eof: bool,
60    stream_end: bool,
61}
62
63impl<'a> InflateReader<'a> {
64    /// Output decompress window per pump; also the compaction threshold.
65    const CHUNK: usize = 64 * 1024;
66    /// Keep one output window between sequential streams. The pump compacts a
67    /// consumed prefix before inflating, so a second window is only needed when
68    /// one individual record genuinely exceeds `CHUNK`.
69    const RETAINED_CAPACITY: usize = Self::CHUNK;
70    /// Cap on retained free-list entries, so concurrently-alive readers on one
71    /// thread don't grow the pool unbounded.
72    const POOL_MAX: usize = 4;
73
74    pub fn new(input: &'a [u8], max: u64) -> Self {
75        let (decomp, buf) = INFLATE_POOL.with(|p| p.borrow_mut().pop()).map_or_else(
76            || {
77                (
78                    Inflate::new(ZLIB_HEADER, WINDOW_BITS),
79                    Vec::with_capacity(Self::CHUNK),
80                )
81            },
82            |(mut decomp, mut buf)| {
83                decomp.reset(ZLIB_HEADER);
84                buf.clear();
85                (decomp, buf)
86            },
87        );
88        Self {
89            input,
90            in_pos: 0,
91            decomp: Some(decomp),
92            buf,
93            cursor: 0,
94            total_out: 0,
95            max,
96            eof: false,
97            stream_end: false,
98        }
99    }
100
101    /// Unparsed decompressed bytes currently buffered.
102    #[inline]
103    pub fn available(&self) -> &[u8] {
104        &self.buf[self.cursor..]
105    }
106
107    /// Mark `n` already-read bytes as consumed.
108    #[inline]
109    pub fn consume(&mut self, n: usize) {
110        self.cursor = (self.cursor + n).min(self.buf.len());
111    }
112
113    /// Ensure at least `need` unparsed bytes are buffered, decompressing more as
114    /// required. Returns `Ok(false)` if the stream ends before reaching `need`.
115    pub fn ensure(&mut self, need: usize) -> io::Result<bool> {
116        while self.buf.len() - self.cursor < need {
117            if self.eof {
118                return Ok(false);
119            }
120            self.pump()?;
121        }
122        Ok(true)
123    }
124
125    /// True once the stream is fully decompressed and all bytes consumed.
126    pub fn is_done(&self) -> bool {
127        self.eof && self.cursor >= self.buf.len()
128    }
129
130    /// Total decompressed bytes produced so far. After the stream ends this is
131    /// the blob's exact inflated size.
132    pub fn total_out(&self) -> u64 {
133        self.total_out
134    }
135
136    /// Compressed input consumed so far plus the input's full length. The
137    /// ratio lets callers extrapolate totals (e.g. record counts) from a
138    /// prefix without a second pass over the blob.
139    #[inline]
140    pub fn compressed_progress(&self) -> (usize, usize) {
141        (self.in_pos, self.input.len())
142    }
143
144    /// Whether zlib reported a proper stream end (terminator + adler32
145    /// checksum). An EOF (`ensure` returning false) without this means the
146    /// input was truncated, not finished.
147    pub fn stream_ended(&self) -> bool {
148        self.stream_end
149    }
150
151    fn pump(&mut self) -> io::Result<()> {
152        // Reclaim the consumed prefix before inflating. Reserving a full output
153        // window on top of a small read-ahead suffix made ordinary framed
154        // streams jump from 64 to 128 KiB even though no record needed it.
155        // Compacting here keeps those streams in one window; a record larger
156        // than the window still grows normally below.
157        if self.cursor != 0 {
158            let remaining = self.buf.len() - self.cursor;
159            self.buf.copy_within(self.cursor.., 0);
160            self.buf.truncate(remaining);
161            self.cursor = 0;
162        }
163
164        // `decomp` is `Some` for the reader's whole lifetime (only `Drop` takes it),
165        // so this is unreachable in practice; surface it as an error rather than panic.
166        let decomp = self
167            .decomp
168            .as_mut()
169            .ok_or_else(|| io::Error::other("InflateReader used after pool return"))?;
170        // Inflate straight into the window's spare capacity: a stack chunk +
171        // extend_from_slice would copy every decompressed byte a second time
172        // (~10% of a history-sync extraction).
173        if self.buf.len() == self.buf.capacity() {
174            self.buf.reserve(Self::CHUNK);
175        }
176        let prev_in = decomp.total_in();
177        let prev_out = decomp.total_out();
178        let status = inflate_into_spare(
179            decomp,
180            &self.input[self.in_pos..],
181            &mut self.buf,
182            InflateFlush::NoFlush,
183        )
184        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.as_str()))?;
185        let new_in = decomp.total_in();
186        let produced = (decomp.total_out() - prev_out) as usize;
187        self.in_pos += (new_in - prev_in) as usize;
188        self.total_out += produced as u64;
189        if self.total_out > self.max {
190            return Err(io::Error::new(
191                io::ErrorKind::InvalidData,
192                format!("decompressed payload exceeds {} bytes", self.max),
193            ));
194        }
195
196        match status {
197            Status::StreamEnd => {
198                self.eof = true;
199                self.stream_end = true;
200            }
201            // No output produced and not at stream end: distinguish a truncated
202            // tail (no input left → treat as end, with `stream_end` left false
203            // so callers can tell it apart from a real terminator) from a
204            // stalled/corrupt stream (input remains but the decompressor
205            // consumed none → error, instead of spinning forever since 64 KB of
206            // output is always available).
207            // Mirrors the no-progress guard in `decompress_zlib_pooled`.
208            _ if produced == 0 => {
209                if self.in_pos >= self.input.len() {
210                    self.eof = true;
211                } else if new_in == prev_in {
212                    return Err(io::Error::new(
213                        io::ErrorKind::InvalidData,
214                        "zlib stream stalled (no progress)",
215                    ));
216                }
217            }
218            _ => {}
219        }
220        Ok(())
221    }
222}
223
224impl Drop for InflateReader<'_> {
225    fn drop(&mut self) {
226        // Return the decompressor + buffer to the per-thread free-list for reuse.
227        // `reset` on the next checkout makes prior stream state (incl. errors) moot.
228        if let Some(decomp) = self.decomp.take() {
229            let mut buf = std::mem::take(&mut self.buf);
230            // A large top-level record (e.g. a big conversation, up to `max`) can
231            // grow `buf` to many MB; don't retain that allocation in the pool for
232            // the thread's lifetime. Keep the one-window steady-state used by
233            // framed streams, but shrink genuinely oversized records.
234            buf.clear();
235            if buf.capacity() > Self::RETAINED_CAPACITY {
236                buf.shrink_to(Self::RETAINED_CAPACITY);
237            }
238            INFLATE_POOL.with(|p| {
239                let mut pool = p.borrow_mut();
240                if pool.len() < Self::POOL_MAX {
241                    pool.push((decomp, buf));
242                }
243            });
244        }
245    }
246}
247
248/// Grow the output buffer by projecting the decompressed size from the
249/// expansion ratio observed so far, instead of blind capacity doubling. A
250/// high-ratio stream (the up-front `2x compressed` guess undershot) then
251/// converges in one or two reallocations sized near the real total, rather
252/// than a doubling chain whose copies and final overshoot dominate both the
253/// allocated-bytes count and the peak.
254fn grow_by_observed_ratio(
255    scratch: &mut Vec<u8>,
256    decompressor: &Inflate,
257    compressed_len: usize,
258    cap: usize,
259) {
260    let consumed = decompressor.total_in() as usize;
261    let produced = decompressor.total_out() as usize;
262    let remaining_in = compressed_len.saturating_sub(consumed) as u64;
263    let projected = if consumed > 0 && produced > 0 {
264        // 9/8 margin: early bytes compress worse than the warmed-up tail, so
265        // the observed ratio slightly underestimates the remainder.
266        ((produced as u64).saturating_mul(remaining_in) / consumed as u64).saturating_mul(9) / 8
267    } else {
268        0
269    };
270    // Floor at the doubling step: small payloads (protocol nodes) keep their
271    // old growth exactly; the projection only ever grows MORE, for the
272    // high-ratio multi-MB streams it exists for.
273    let min_grow = scratch.capacity().max(4096);
274    let want = (projected.min(usize::MAX as u64) as usize)
275        .max(min_grow)
276        .min(cap - scratch.len());
277    scratch.reserve(want);
278}
279
280/// Decompress zlib data using a pooled decompressor.
281///
282/// Reuses the per-thread `zlib_rs::Inflate` internal state (~48 KB) across
283/// calls. The output buffer is taken by the caller (zero-copy), so it is sized
284/// up-front from the compressed length to avoid repeated doubling reallocations
285/// while it grows to the decompressed size.
286pub fn decompress_zlib_pooled(compressed: &[u8], max_size: u64) -> io::Result<Vec<u8>> {
287    DECOMPRESSOR.with(|cell| {
288        let (decompressor, scratch) = &mut *cell.borrow_mut();
289        decompressor.reset(ZLIB_HEADER);
290        scratch.clear();
291
292        // Cap output growth to max_size + 1 so we detect oversized payloads
293        // without allocating unbounded memory from a compressed bomb.
294        let cap = (max_size as usize).saturating_add(1);
295
296        // Pre-size the output near the likely decompressed size to avoid the
297        // repeated doubling reallocations the old 64 KB upper clamp forced for
298        // every multi-MB history-sync chunk. 2x the compressed length is a
299        // conservative first guess (zlib here compresses ~2-5x): it rarely
300        // overshoots the real size, so it cuts reallocations without inflating
301        // peak memory. Bounded by `cap` so a bad guess can't exceed the limit;
302        // the floor also bows to `cap` because callers now pass exact (possibly
303        // tiny) decompressed sizes as the limit, where a fixed 4096 floor would
304        // invert the clamp and panic.
305        let floor = 4096.min(cap);
306        let estimated = compressed.len().saturating_mul(2).clamp(floor, cap);
307        if scratch.capacity() < estimated {
308            scratch.reserve(estimated - scratch.capacity());
309        }
310
311        let mut input_offset = 0;
312        loop {
313            // Enforce cap before we grow the buffer for the next inflate call
314            if scratch.len() >= cap {
315                return Err(io::Error::new(
316                    io::ErrorKind::InvalidData,
317                    format!("decompressed payload exceeds {max_size} bytes"),
318                ));
319            }
320
321            let prev_in = decompressor.total_in();
322            let prev_out = decompressor.total_out();
323
324            let status = inflate_into_spare(
325                decompressor,
326                &compressed[input_offset..],
327                scratch,
328                InflateFlush::Finish,
329            )
330            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.as_str()))?;
331
332            input_offset = decompressor.total_in() as usize;
333
334            if scratch.len() as u64 > max_size {
335                return Err(io::Error::new(
336                    io::ErrorKind::InvalidData,
337                    format!("decompressed payload exceeds {max_size} bytes"),
338                ));
339            }
340
341            match status {
342                Status::StreamEnd => break,
343                Status::Ok => {
344                    grow_by_observed_ratio(scratch, decompressor, compressed.len(), cap);
345                }
346                Status::BufError => {
347                    if decompressor.total_in() == prev_in && decompressor.total_out() == prev_out {
348                        return Err(io::Error::new(
349                            io::ErrorKind::InvalidData,
350                            "zlib stream truncated (no progress)",
351                        ));
352                    }
353                    grow_by_observed_ratio(scratch, decompressor, compressed.len(), cap);
354                }
355            }
356        }
357
358        // Move the Vec out (zero-copy), then restore scratch with fresh capacity.
359        // Callers (unpack_bytes, history_sync) wrap in Bytes::from() which takes
360        // ownership of the Vec's allocation, so no extra copy occurs.
361        let result = std::mem::take(scratch);
362        // Pre-allocate for next call so the first decompress_vec doesn't start at 0
363        scratch.reserve(4096);
364        Ok(result)
365    })
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use flate2::Compression;
372    use flate2::write::ZlibEncoder;
373    use std::io::Write;
374
375    fn zlib(data: &[u8]) -> Vec<u8> {
376        let mut e = ZlibEncoder::new(Vec::new(), Compression::default());
377        e.write_all(data).unwrap();
378        e.finish().unwrap()
379    }
380
381    fn varied(n: usize) -> Vec<u8> {
382        let mut s: u64 = 0x9e37_79b9_7f4a_7c15;
383        (0..n)
384            .map(|_| {
385                s ^= s << 13;
386                s ^= s >> 7;
387                s ^= s << 17;
388                (s >> 24) as u8
389            })
390            .collect()
391    }
392
393    /// A zlib stream carrying `data` verbatim in one stored (uncompressed)
394    /// deflate block, hand-built so the fixture costs no compressor.
395    ///
396    /// `zlib()` above cannot be used under Miri: zlib-rs 0.6.6's *deflate* state
397    /// frees its buffers from `deflate::end` while a `&mut` into them is still
398    /// protected, which Miri rejects. That is the compression half, which this
399    /// crate never runs — inflate is the whole production path — so the fixture
400    /// side steps around it rather than the test being dropped.
401    fn stored_zlib(data: &[u8]) -> Vec<u8> {
402        assert!(data.len() <= u16::MAX as usize, "one stored block only");
403        // 0x78 0x01: deflate, 32 KB window, and (0x78 << 8 | 0x01) % 31 == 0 as
404        // the header check requires.
405        let mut out = vec![0x78, 0x01];
406        let len = data.len() as u16;
407        // BFINAL=1, BTYPE=00 (stored), then the byte-aligned LEN/!LEN pair.
408        out.push(0x01);
409        out.extend_from_slice(&len.to_le_bytes());
410        out.extend_from_slice(&(!len).to_le_bytes());
411        out.extend_from_slice(data);
412
413        let (mut a, mut b) = (1u32, 0u32);
414        for &byte in data {
415            a = (a + byte as u32) % 65521;
416            b = (b + a) % 65521;
417        }
418        out.extend_from_slice(&(((b << 16) | a).to_be_bytes()));
419        out
420    }
421
422    // Every other test here is sized in hundreds of KB to MB — the only way to
423    // reach window refill, the growth projection and shrink-on-return — which
424    // puts a full inflate cycle hours out of reach of Miri's interpreter, so
425    // they are `#[cfg_attr(miri, ignore)]`. This one keeps the `set_len` in
426    // `inflate_into_spare` under Miri on a fixture it can finish.
427    #[test]
428    fn pooled_roundtrip_small_input() {
429        let original = varied(1024);
430        let compressed = stored_zlib(&original);
431        assert_eq!(
432            decompress_zlib_pooled(&compressed, 64 * 1024).unwrap(),
433            original
434        );
435        assert_eq!(drain_reader(&compressed, original.len()), original);
436    }
437
438    #[test]
439    #[cfg_attr(miri, ignore)]
440    fn inflate_reader_roundtrip_across_chunks() {
441        // >128 KB so the stream spans multiple 64 KB decompress windows, and read
442        // it back in tiny odd steps to exercise refill + compaction.
443        let original = varied(200 * 1024);
444        let compressed = zlib(&original);
445        let mut r = InflateReader::new(&compressed, 64 * 1024 * 1024);
446        let mut out = Vec::with_capacity(original.len());
447        while r.ensure(1).unwrap() {
448            let n = r.available().len().min(7);
449            out.extend_from_slice(&r.available()[..n]);
450            r.consume(n);
451        }
452        assert!(r.is_done());
453        assert_eq!(out, original);
454    }
455
456    #[test]
457    #[cfg_attr(miri, ignore)]
458    fn inflate_reader_ensure_larger_than_chunk() {
459        // A single record bigger than the 64 KB window must be fully buffered.
460        let original: Vec<u8> = (0..150 * 1024).map(|i| (i % 256) as u8).collect();
461        let compressed = zlib(&original);
462        let mut r = InflateReader::new(&compressed, 64 * 1024 * 1024);
463        assert!(r.ensure(150 * 1024).unwrap());
464        assert_eq!(&r.available()[..150 * 1024], &original[..]);
465    }
466
467    #[test]
468    #[cfg_attr(miri, ignore)]
469    fn inflate_reader_keeps_one_window_for_smaller_records() {
470        INFLATE_POOL.with(|p| p.borrow_mut().clear());
471        const RECORD: usize = 30 * 1024;
472        let original = varied(RECORD * 8);
473        let compressed = zlib(&original);
474        let mut r = InflateReader::new(&compressed, 64 * 1024 * 1024);
475
476        for expected in original.chunks(RECORD) {
477            assert!(r.ensure(expected.len()).unwrap());
478            assert_eq!(&r.available()[..expected.len()], expected);
479            r.consume(expected.len());
480            assert!(
481                r.buf.capacity() <= InflateReader::CHUNK,
482                "sub-window records grew the inflate buffer to {} bytes",
483                r.buf.capacity()
484            );
485        }
486        assert!(!r.ensure(1).unwrap());
487        assert!(r.is_done());
488    }
489
490    #[test]
491    #[cfg_attr(miri, ignore)]
492    fn inflate_reader_enforces_max() {
493        let original = vec![0u8; 1024 * 1024];
494        let compressed = zlib(&original);
495        let mut r = InflateReader::new(&compressed, 4096);
496        assert!(r.ensure(1024 * 1024).is_err());
497    }
498
499    #[test]
500    #[cfg_attr(miri, ignore)]
501    fn pooled_high_ratio_stream_roundtrips() {
502        // ~50x expansion: the 2x up-front guess undershoots badly, so this
503        // exercises the ratio-projected growth path end to end.
504        let original: Vec<u8> = (0..4_000_000u32).map(|i| ((i / 1024) % 7) as u8).collect();
505        let compressed = zlib(&original);
506        assert!(
507            compressed.len() < original.len() / 20,
508            "fixture not high-ratio"
509        );
510        let out = decompress_zlib_pooled(&compressed, 64 * 1024 * 1024).unwrap();
511        assert_eq!(out, original);
512        // The projection should land near the real size, not at a doubling
513        // overshoot far past it.
514        assert!(
515            out.capacity() < original.len() * 2,
516            "capacity {} vs data {}",
517            out.capacity(),
518            original.len()
519        );
520    }
521
522    #[test]
523    #[cfg_attr(miri, ignore)]
524    fn pooled_oneshot_matches_streaming() {
525        let original = varied(100_000);
526        let compressed = zlib(&original);
527        let one_shot = decompress_zlib_pooled(&compressed, 64 * 1024 * 1024).unwrap();
528        assert_eq!(one_shot, original);
529    }
530
531    fn drain_reader(compressed: &[u8], n: usize) -> Vec<u8> {
532        let mut r = InflateReader::new(compressed, 64 * 1024 * 1024);
533        let mut out = Vec::with_capacity(n);
534        while r.ensure(1).unwrap() {
535            let take = r.available().len();
536            out.extend_from_slice(r.available());
537            r.consume(take);
538        }
539        assert!(r.is_done());
540        out
541    }
542
543    #[test]
544    #[cfg_attr(miri, ignore)]
545    fn inflate_reader_reuses_pool_state_correctly() {
546        // Back-to-back readers each checkout the pooled Decompress and reset it, so
547        // no state may carry over between streams. Verify several sizes in sequence.
548        for n in [10_000usize, 250_000, 1, 80_000] {
549            let original = varied(n);
550            assert_eq!(drain_reader(&zlib(&original), n), original, "size {n}");
551        }
552    }
553
554    #[test]
555    #[cfg_attr(miri, ignore)]
556    fn inflate_reader_reuse_after_error() {
557        // A reader aborted mid-stream (max exceeded) returns partial zlib state to
558        // the pool; the next checkout must reset it and decompress a full stream.
559        {
560            let compressed = zlib(&varied(500_000));
561            let mut r = InflateReader::new(&compressed, 4096);
562            assert!(r.ensure(500_000).is_err());
563        }
564        let original = varied(120_000);
565        assert_eq!(drain_reader(&zlib(&original), 120_000), original);
566    }
567
568    #[test]
569    #[cfg_attr(miri, ignore)]
570    fn drop_shrinks_oversized_buffer_before_pooling() {
571        // Buffering a large record grows `buf` to many MB; on return to the pool it
572        // must be shrunk back toward the bounded steady-state capacity, not parked
573        // at full size for the thread.
574        INFLATE_POOL.with(|p| p.borrow_mut().clear());
575        let big = varied(2 * 1024 * 1024);
576        let compressed = zlib(&big);
577        {
578            let mut r = InflateReader::new(&compressed, 64 * 1024 * 1024);
579            assert!(r.ensure(big.len()).unwrap());
580            assert!(r.buf.capacity() >= big.len(), "buf should grow while alive");
581        }
582        let pooled = INFLATE_POOL.with(|p| p.borrow().last().map(|(_, b)| b.capacity()));
583        assert!(
584            matches!(pooled, Some(cap) if cap <= InflateReader::RETAINED_CAPACITY),
585            "pooled buffer not shrunk: {pooled:?}"
586        );
587    }
588}