Skip to content

feat(decoding): block-precise error position on FrameDecoderError variants #174

@polaz

Description

@polaz

⚠️ 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

  • Corrupt a known block header at index 7 in a multi-block frame → decode → FrameDecoderError::BlockHeaderReadError { block_index: 7, frame_offset: <expected>, .. }.
  • Corrupt body of block 3 → DecodeBlockContentError { block_index: 3, block: FrameBlock { offset_in_frame: <expected>, .. }, .. }.
  • frame_offset consistent with FrameEmitInfo.blocks[block_index].offset_in_frame from feat(encoding+decoding): FrameEmitInfo block-layout introspection + opt-in per-block XXH64 sidecar #173.
  • No regression on happy-path benches (the bookkeeping is one u32 per block, already tracked).
  • Existing downstream consumers that match on BlockHeaderReadError(source) need to migrate to the new struct shape — documented in CHANGELOG.
  • 503/503 lib tests pass.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2-mediumMedium priority — important improvementenhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions