Re-initializing a seekable decoder leaks its previous seek table
perfloop-oss/zstd · RESOURCE LEAK
https://perfloop.ai/t/oss/case_fmct4q2qea
Verdict
VERIFIED · settled 2026-11-13
What happened: The check fails before this change and passes with it.
Hypothesis
A seekable decoder can be initialized again with a new archive. When that succeeds, the loader stores the new seek table over the old pointer without freeing the old table. The old table's memory then stays allocated for the life of the process. The leak is reproduced below. How often callers re-initialize decoders is not measured.
In ZSTD_seekable_loadSeekTable (contrib/seekable_format/zstdseek_decompress.c, lines 374-463), a successful load assigns zs->seekTable.entries at line 457 and never looks at the previous value. ZSTD_seekable_free (lines 247-254) frees only the array that is installed at the end. ZSTD_seekable_initBuff and ZSTD_seekable_initFile both delegate to ZSTD_seekable_initAdvanced, which calls the loader, so all three init functions are affected.
Re-initialization is part of the documented lifecycle. zstd_seekable.h says the source must stay valid "until the ZSTD_seekable object is freed or reset", and there is no separate reset call. ZSTD_seekable_create zeroes the struct, so the first init has no previous table. ZSTD_seekTable_create_fromSeekable deep-copies the entries, so freeing the replaced array cannot invalidate a seek table a caller exported earlier.
Property prop_326agj0j8j stores a native C harness: contrib/seekable_format/tests/loadseektable_ownership_test.c, run by loadseektable_ownership_check.sh. It counts allocations with linker-wrapped malloc/calloc/realloc/free and drives only the public API. At revision 399405a5, every pair of successful inits on one decoder leaked the first table after ZSTD_seekable_free. That was 24 bytes for an empty table, 48 bytes for a 1-frame table, 393,240 bytes for 16,384 frames and 960,024 bytes for 40,000 frames, with and without checksums. A single successful init followed by free left nothing live. One case replays with `sh contrib/seekable_format/tests/loadseektable_ownership_check.sh double 0 0 0 0` or `sh contrib/seekable_format/tests/loadseektable_ownership_check.sh double 1 40000 2 0`. The full sweep also reports a separate leak when a load fails at a refill read, which this change does not address.
The leak happens on every successful re-init of the same decoder, whatever the table size. Each re-init leaks the previous table's (frames + 1) x 24 bytes. A long-lived decoder reused across many archives keeps accumulating them. Re-init frequency in real callers is unknown.
No open upstream PR changes this. https://github.com/meta/zstd/pull/4902 changes the ZSTD_seekable_decompress range checks. https://github.com/meta/zstd/pull/4758 and https://github.com/meta/zstd/pull/4712 change an accessor bound, and https://github.com/meta/zstd/pull/4839 adds a default return to the seek callbacks.
The Case can confirm the fix when every `double` replay with no injected failure reports 0 violations, a failed re-init still leaves the previous table usable until free, and the existing contrib/seekable_format tests still pass.
Change to test: When a newly loaded table is installed at line 457, free the table it replaces. This is safe because a new decoder starts with a NULL table and exported seek tables are deep copies. Keep the current behavior where a failed re-init leaves the previous table installed until free.
Where it lives
perfloop-oss/zstd · contrib/seekable_format/zstdseek_decompress.c
Evidence
The check fails before this change and passes with it: `Seekable decoder initialization releases replaced seek tables on success and unpublished tables on read failure, preserves previously installed table metadata on failure and independently exported tables, and leaves no allocations after their owners are freed, for valid archives through all three public initialization APIs.`
Checks: 3 of 3 passed. Verification: no defect found.