Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions zstd/src/encoding/match_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7178,17 +7178,55 @@ fn hc_chain_candidates_returns_sentinels_for_short_suffix() {
}

#[test]
fn hc_reset_refills_existing_tables_with_empty_sentinel() {
fn hc_reset_advances_floor_past_prior_frame_entries() {
use super::match_table::storage::MatchTable;
let mut hc = HcMatchGenerator::new(32);
hc.table.add_data(b"abcdeabcde".to_vec(), |_| {});
hc.table.ensure_tables();
// Populate real hash / chain entries for the first frame's positions.
hc.table.insert_positions(0, 6);
let prev_end = hc.table.history_abs_end();
assert_eq!(prev_end, 10);
assert!(hc.table.hash_table.iter().any(|&v| v != HC_EMPTY));

hc.reset(|_| {});

// Behavioural contract: the previous frame's entries are no longer
// matchable. `reset` advances the floor past every prior position
// instead of zeroing the tables, so each populated slot now decodes
// to an absolute position strictly below `history_abs_start` and is
// rejected by the `window_low` guard before any byte is read.
assert_eq!(hc.table.history_abs_start, prev_end);
for &slot in hc.table.hash_table.iter() {
if let Some(candidate_abs) =
MatchTable::stored_abs_position_fast(slot, hc.table.position_base, hc.table.index_shift)
{
assert!(
candidate_abs < hc.table.history_abs_start,
"a prior-frame entry must resolve below the advanced floor"
);
}
}
}

#[test]
fn hc_reset_full_zeroes_when_floor_would_cross_ceiling() {
use super::match_table::storage::REBASE_RESET_FLOOR_CEILING;
let mut hc = HcMatchGenerator::new(32);
hc.table.add_data(b"abcdeabcde".to_vec(), |_| {});
hc.table.ensure_tables();
assert!(!hc.table.hash_table.is_empty());
assert!(!hc.table.chain_table.is_empty());
hc.table.hash_table.fill(123);
hc.table.chain_table.fill(456);
// Push the would-be floor (`history_abs_end`) past the ceiling so
// `reset` takes the bounded fallback: rewind to the origin and zero
// the tables, keeping the absolute cursor from climbing toward
// `usize::MAX` on 32-bit targets.
hc.table.history_abs_start = REBASE_RESET_FLOOR_CEILING;

hc.reset(|_| {});

assert_eq!(hc.table.history_abs_start, 0);
assert_eq!(hc.table.position_base, 0);
assert!(hc.table.hash_table.iter().all(|&v| v == HC_EMPTY));
assert!(hc.table.chain_table.iter().all(|&v| v == HC_EMPTY));
}
Expand Down
90 changes: 70 additions & 20 deletions zstd/src/encoding/match_table/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ use super::helpers::INCOMPRESSIBLE_SKIP_STEP;
/// 32-bit targets without per-call saturating guards.
pub(crate) const STREAM_ABS_HEADROOM: usize = HC_OPT_NUM + 16;

/// Ceiling on the absolute-position floor (`history_abs_start`) that the
/// rebase-style `reset` is allowed to advance to before it falls back to a
/// full table zeroing.
///
/// `reset` invalidates the previous frame's table entries by advancing
/// `history_abs_start` past the previous frame's end (a per-position
/// `window_low` reject then drops every stale candidate before it is ever
/// dereferenced), instead of memset-ing the hash / chain / hash3 tables.
/// That keeps the absolute cursor monotonic across independent frames in a
/// reused compressor, so the floor grows by one frame per `reset`. Once it
/// crosses this ceiling, `reset` does the original full zeroing and rewinds
/// the floor to `0`, which bounds `history_abs_start` so
/// [`check_stream_abs_headroom`] stays satisfiable on 32-bit targets (where
/// `usize::MAX` is ~4.29e9): the fallback fires roughly once per 2 GiB of
/// cumulative input on a 32-bit build and is astronomically far on 64-bit.
pub(crate) const REBASE_RESET_FLOOR_CEILING: usize = usize::MAX >> 1;

/// Frame-level overflow gate shared by `MatchTable`,
/// `DfastMatchGenerator`, and `RowMatchGenerator`.
///
Expand Down Expand Up @@ -640,36 +657,69 @@ impl MatchTable {
Some(shifted - index_shift)
}

/// Reset the per-frame portion of the storage. The hash / chain /
/// hash3 tables themselves are zeroed in place (via
/// `Vec::fill(HC_EMPTY)`) if they're already sized; otherwise
/// they're left empty so the next `ensure_tables()` call resizes
/// them. The window is now just `chunk_lens` (the bytes live in
/// `history`, which this method clears below), so there are no
/// per-block buffers to drain; `_reuse_space` is retained only for
/// caller signature compatibility and is intentionally unused.
/// Reset the per-frame portion of the storage for the next
/// independent frame.
///
/// Rather than memset-ing the hash / chain / hash3 tables (the cost
/// of which is proportional to table size, paid once per frame in a
/// reused compressor), this advances the absolute-position floor
/// `history_abs_start` past the previous frame's end. Every table
/// walker rejects a candidate whose absolute position is below the
/// floor (`window_low >= history_abs_start`) before it dereferences
/// any history byte or trusts a binary-tree seed length, so the
/// previous frame's stale entries become unreachable without being
/// cleared. `position_base` / `index_shift` are left untouched so the
/// stale entries still decode to their (now sub-floor) absolute
/// positions; the per-position rebase guard
/// ([`Self::maybe_rebase_positions`]) handles the rare `u32`
/// representability rollover as the cursor keeps climbing.
///
/// When advancing the floor would push it past
/// [`REBASE_RESET_FLOOR_CEILING`], the original full zeroing runs and
/// the floor rewinds to `0`, bounding the absolute cursor so
/// [`check_stream_abs_headroom`] stays satisfiable on 32-bit targets.
/// The window is just `chunk_lens` (the bytes live in `history`,
/// cleared below), so there are no per-block buffers to drain;
/// `_reuse_space` is retained only for caller signature compatibility
/// and is intentionally unused.
pub(crate) fn reset(&mut self, _reuse_space: impl FnMut(Vec<u8>)) {
// Snapshot the previous frame's one-past-the-end absolute
// position before clearing the history that `history_abs_end`
// reads. Every stale table entry points strictly below this.
let next_floor = self.history_abs_end();
self.window_size = 0;
self.chunk_lens.clear();
self.history.clear();
self.history_start = 0;
self.history_abs_start = 0;
self.position_base = 0;
self.index_shift = 0;
self.offset_hist = [1, 4, 8];
self.next_to_update3 = 0;
self.skip_insert_until_abs = 0;
self.dictionary_limit_abs = None;
self.dictionary_primed_for_frame = false;
self.allow_zero_relative_position = false;
// Clear each table independently — `Vec::fill` on an empty Vec
// is a no-op, so unconditional fills are safe even when a table
// hasn't been allocated yet (HC mode keeps hash3_table empty,
// and the backend-switch path swaps every table for Vec::new()
// to release oversized allocations).
self.hash_table.fill(HC_EMPTY);
self.hash3_table.fill(HC_EMPTY);
self.chain_table.fill(HC_EMPTY);
if next_floor <= REBASE_RESET_FLOOR_CEILING {
// Fast path: advance the floor so the previous frame's
// entries fall below `window_low` and are rejected on read.
// The tables keep their contents (and their sizing — a later
// `ensure_tables` call still reallocates them clean if the
// next level's config changed their dimensions).
self.history_abs_start = next_floor;
self.next_to_update3 = next_floor;
} else {
// Bounded fallback: rewind the cursor to the origin and zero
// the tables so `history_abs_start` cannot climb toward
// `usize::MAX`. Clear each table independently — `Vec::fill`
// on an empty Vec is a no-op, so unconditional fills are safe
// even when a table hasn't been allocated yet (HC mode keeps
// hash3_table empty, and the backend-switch path swaps every
// table for Vec::new() to release oversized allocations).
self.history_abs_start = 0;
self.position_base = 0;
self.index_shift = 0;
self.next_to_update3 = 0;
self.hash_table.fill(HC_EMPTY);
self.hash3_table.fill(HC_EMPTY);
self.chain_table.fill(HC_EMPTY);
}
}

/// Donor parity: `ZSTD_compressBlock_btopt_generic` starts its main
Expand Down
47 changes: 47 additions & 0 deletions zstd/tests/cross_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,53 @@ fn cross_rust_compress_ffi_decompress_1000() {
}
}

/// Regression for the rebase-style `reset`: a reused compressor advances
/// the absolute-position floor across independent frames instead of zeroing
/// the matcher tables. Every frame must still decode through C zstd, proving
/// the previous frame's stale table entries never leak into the next frame's
/// match decisions. Level 22 (optimal parser, binary-tree backend) is the
/// most sensitive path; the frames are sized into one source-size tier so the
/// matcher reuses the same tables and the floor-advance path (not a realloc)
/// is the one exercised.
#[test]
fn cross_rust_reused_compressor_level22_ffi_decompress() {
let mut enc: FrameCompressor = FrameCompressor::new(CompressionLevel::from_level(22));
for i in 0..64u64 {
// ~4 KiB, same tier each iteration; distinct, compressible content.
let data = generate_huffman_friendly(i.wrapping_add(1), 4096, 24);
let compressed = enc.compress_independent_frame(&data);
let result = zstd::decode_all(compressed.as_slice()).unwrap_or_else(|e| {
panic!("reused-compressor rust→ffi decode failed at frame {i}: {e}");
});
assert_eq!(
data, result,
"reused-compressor rust→ffi roundtrip failed at frame {i}"
);
}
}

/// Companion to the level-22 reuse test that also varies the frame size so
/// the source-size tier (and therefore the table dimensions) changes between
/// frames. This exercises the path where `reset` advances the floor but a
/// later `ensure_tables` reallocates the tables clean, alongside the pure
/// floor-advance path.
#[test]
fn cross_rust_reused_compressor_varied_sizes_ffi_decompress() {
let mut enc: FrameCompressor = FrameCompressor::new(CompressionLevel::from_level(19));
for i in 0..64u64 {
let len = (1024 + (i * 1531) % 96_000) as usize;
let data = generate_huffman_friendly(i.wrapping_add(7), len, 40);
let compressed = enc.compress_independent_frame(&data);
let result = zstd::decode_all(compressed.as_slice()).unwrap_or_else(|e| {
panic!("varied-size reuse rust→ffi decode failed at frame {i}, len={len}: {e}");
});
assert_eq!(
data, result,
"varied-size reuse rust→ffi roundtrip failed at frame {i}, len={len}"
);
}
}

#[test]
fn cross_rust_fastest_with_source_hint_ffi_decompress_iteration_23() {
let i = 23u64;
Expand Down
Loading