⚠️ Deferred — post-Phase 6 drop-in parity (2026-05-19)
Project priority sequence per #28: complete encoder rewrite (#111 incl. #23) → speed/ratio optimizations (#178, #180) → params API (#27) → magicless (#26) → Phase 6 C-ABI / CLI drop-in (#126/#127/#128/#130/#131/#132) → THEN this track. lsm-tree bilateral coordination accepted 2026-05-18 — commitment preserved, but execution defers until drop-in parity ships. Pre-Phase-6 work on this issue will not be scheduled.
⚠️ Feature gate (mandatory): all Rust code added by this issue is compiled only when the lsm Cargo feature is enabled (#[cfg(feature = "lsm")] on every new public item — module, struct, enum variant, impl block, function). Feature is default off, opt-in for downstream consumers. Without lsm: build is byte-identical to today, no new public symbols, cdylib from Phase 6 stays strict drop-in for donor libzstd v1.5.7. C FFI surface is unaffected regardless of feature state.
Context
Today FrameDecoderError::BlockHeaderReadError(...) and DecodeBlockContentError(...) (zstd/src/decoding/errors.rs) tell the caller that something failed, not where. For consumers doing per-block recovery (ECC repair in lsm-tree's planned LSM-T5/T6 path) the missing block coordinate is the difference between "repair one block" and "give up, re-fetch the whole frame from parity."
Scope — Rust-side only, additive via #[non_exhaustive] slack
Extend the existing variants with positional fields. The FrameDecoderError enum is already #[non_exhaustive] (no breaking change for downstream pattern-matchers):
// zstd/src/decoding/errors.rs
#[non_exhaustive]
pub enum FrameDecoderError {
// existing variants ...
BlockHeaderReadError {
source: BlockHeaderReadError,
block_index: u32, // 0-based index of the failing block in the frame
frame_offset: u32, // byte offset where the read attempt happened
},
DecodeBlockContentError {
source: DecodeBlockContentError,
block_index: u32,
frame_offset: u32,
block: FrameBlock, // from #173 — the failing block's structural metadata
},
}
(Names of existing variants stay the same; fields are added — that's a breaking change only for pattern matches that name fields explicitly with .. rest pattern omitted, which is rare in practice and explicitly the case #[non_exhaustive] is designed for.)
Threading block_index through the decode loop
FrameDecoder already counts blocks (state.block_counter, frame_decoder.rs:440-446 exposes it via blocks_decoded()). Plumb it into the error-construction sites so the value is captured at the moment the error is raised rather than reconstructed afterward.
C FFI parity
C FFI surface unchanged. The donor's ZSTD_decompressStream / ZSTD_decompressDCtx return flat size_t error codes with ZSTD_isError() sentinel — no positional info. Phase 6 #126/#127 wrappers continue to return that flat surface; the new Rust fields are Rust-side only.
Acceptance criteria
Estimated size
~100 LoC + targeted tests. PR-D in the bilateral phasing. Independent of T1/T2/T3 — can land any order, but most useful AFTER T3 (#173) so FrameBlock is available to populate the block: FrameBlock field on DecodeBlockContentError.
Related
ADDENDUM (2026-05-18): feature gating
The new positional fields on FrameDecoderError variants are gated behind the lsm Cargo feature (default off). Without lsm, the existing variants keep their current shape and downstream pattern matches do not break.
Approach: add NEW variants behind the feature gate rather than mutate existing ones, so the no-feature build's enum is byte-identical to today:
#[non_exhaustive]
pub enum FrameDecoderError {
// existing variants (unconditional, unchanged)
BlockHeaderReadError(BlockHeaderReadError),
DecodeBlockContentError(DecodeBlockContentError),
// NEW, only when feature is enabled — provides positional context
#[cfg(feature = "lsm")]
BlockHeaderReadErrorAt {
source: BlockHeaderReadError,
block_index: u32,
frame_offset: u32,
},
#[cfg(feature = "lsm")]
DecodeBlockContentErrorAt {
source: DecodeBlockContentError,
block_index: u32,
frame_offset: u32,
block: FrameBlock, // from #173, also feature-gated
},
}
When lsm is enabled, the decoder constructs the ...At variants; when disabled, it constructs the legacy variants. C FFI error mapping (ZSTD_getErrorName etc) collapses both to the same flat error code regardless of feature.
This preserves the drop-in C FFI parity invariant: the cdylib build with default features sees exactly the donor's flat error surface.
Bilateral cross-reference
Context
Today
FrameDecoderError::BlockHeaderReadError(...)andDecodeBlockContentError(...)(zstd/src/decoding/errors.rs) tell the caller that something failed, not where. For consumers doing per-block recovery (ECC repair in lsm-tree's planned LSM-T5/T6 path) the missing block coordinate is the difference between "repair one block" and "give up, re-fetch the whole frame from parity."Scope — Rust-side only, additive via
#[non_exhaustive]slackExtend the existing variants with positional fields. The
FrameDecoderErrorenum is already#[non_exhaustive](no breaking change for downstream pattern-matchers):(Names of existing variants stay the same; fields are added — that's a breaking change only for pattern matches that name fields explicitly with
..rest pattern omitted, which is rare in practice and explicitly the case#[non_exhaustive]is designed for.)Threading
block_indexthrough the decode loopFrameDecoderalready counts blocks (state.block_counter,frame_decoder.rs:440-446exposes it viablocks_decoded()). Plumb it into the error-construction sites so the value is captured at the moment the error is raised rather than reconstructed afterward.C FFI parity
C FFI surface unchanged. The donor's
ZSTD_decompressStream/ZSTD_decompressDCtxreturn flatsize_terror codes withZSTD_isError()sentinel — no positional info. Phase 6 #126/#127 wrappers continue to return that flat surface; the new Rust fields are Rust-side only.Acceptance criteria
FrameDecoderError::BlockHeaderReadError { block_index: 7, frame_offset: <expected>, .. }.DecodeBlockContentError { block_index: 3, block: FrameBlock { offset_in_frame: <expected>, .. }, .. }.frame_offsetconsistent withFrameEmitInfo.blocks[block_index].offset_in_framefrom feat(encoding+decoding): FrameEmitInfo block-layout introspection + opt-in per-block XXH64 sidecar #173.u32per block, already tracked).BlockHeaderReadError(source)need to migrate to the new struct shape — documented in CHANGELOG.Estimated size
~100 LoC + targeted tests. PR-D in the bilateral phasing. Independent of T1/T2/T3 — can land any order, but most useful AFTER T3 (#173) so
FrameBlockis available to populate theblock: FrameBlockfield onDecodeBlockContentError.Related
FrameBlockstruct).ADDENDUM (2026-05-18): feature gating
The new positional fields on
FrameDecoderErrorvariants are gated behind thelsmCargo feature (default off). Withoutlsm, the existing variants keep their current shape and downstream pattern matches do not break.Approach: add NEW variants behind the feature gate rather than mutate existing ones, so the no-feature build's enum is byte-identical to today:
When
lsmis enabled, the decoder constructs the...Atvariants; when disabled, it constructs the legacy variants. C FFI error mapping (ZSTD_getErrorNameetc) collapses both to the same flat error code regardless of feature.This preserves the drop-in C FFI parity invariant: the
cdylibbuild with default features sees exactly the donor's flat error surface.Bilateral cross-reference
block_indexfromFrameDecoderErrorvariants to trigger targeted ECC repair only on the failing block instead of pre-checking all block checksums.