Raise instead of hanging on a truncated frame in Zstd.decompress - #143
Open
Watson1978 wants to merge 1 commit into
Open
Raise instead of hanging on a truncated frame in Zstd.decompress#143Watson1978 wants to merge 1 commit into
Watson1978 wants to merge 1 commit into
Conversation
decode_one_frame looped until ZSTD_decompressStream returned 0. For a
truncated/incomplete frame libzstd keeps returning a non-zero "need more
input" hint while consuming and producing nothing, so the loop spun
forever. Because ZSTD_decompressStream is called directly (GVL held),
this froze the whole VM at 100% CPU and ignored SIGTERM.
A header-only frame reproduces it:
Zstd.decompress("\x28\xB5\x2F\xFD") # hung forever
Detect the no-progress case (no output produced and no input consumed
with a non-zero return) and raise, matching the streaming decompressor
which already stops when the input is exhausted. Add a regression spec.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Watson1978
force-pushed
the
fix/decompress-truncated-frame-hang
branch
from
August 6, 2026 19:31
77fb845 to
744cbeb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Zstd.decompresshangs forever on a truncated / incomplete frame.decode_one_frameloops untilZSTD_decompressStreamreturns0. For a truncated frame, libzstd keeps returning a non-zero "need more input" hint while consuming and producing nothing, so the loop never terminates. BecauseZSTD_decompressStreamis called directly with the GVL held, this freezes the whole Ruby VM at 100% CPU: other threads stop, GC checkpoints are never reached,Timeout::timeoutandThread#killhave no effect, and the process does not even respond toSIGTERM(onlySIGKILLstops it).A header-only frame reproduces it:
This is reachable from any input source that feeds attacker-controlled compressed bytes to
Zstd.decompress, for example a compressed network payload.Raising here matches
Zstd::StreamingDecompress#decompress, which already stops once the input is exhausted and returns gracefully on the same input.This is a regression
Before 856938d (
Fix Zstd.decompress glitches),rb_decompresscalledZSTD_getFrameContentSizeup front. For a truncated frame that returnsZSTD_CONTENTSIZE_ERROR, so the same input raised"not compressed by zstd"instead of hanging. TheZSTD_CONTENTSIZE_UNKNOWNpath went throughdecompress_buffered, whose loop condition waswhile (input.pos < input.size)and therefore also terminated.856938d replaced both paths with the current
for (;;)loop, whose only exit conditions are a libzstd error andret == 0. Neither holds for a truncated frame. The hang first shipped in v2.0.1 and is present through the current v2.0.7.Scope: what this PR does not change
This PR does not touch GVL handling.
ZSTD_decompressStreamis still called directly, with the GVL held, exactly as it is onmain.Releasing the GVL here would additionally make the loop interruptible, and it would make one-shot decompression consistent with
Zstd.compressandStreamingDecompress#decompress, which already go through therb_thread_call_without_gvlwrappers. I deliberately left it out of this PR because it is a separate decision with its own history: #133 proposed exactly that change and was closed the same day pointing at #112, whose root cause turned out to be GC compaction and GC'd pointer lifetime (fixed in #116). Releasing the GVL while libzstd holds aRSTRING_PTRinto the input String belongs in its own discussion, and it should not gate a fix for an unkillable hang. I am happy to open a separate PR or issue for it if that is wanted.Compatibility
The only inputs whose behavior changes are ones that currently hang forever. Every input that currently returns a value still returns the same value, and every input that currently raises still raises. Valid single and concatenated frames are unaffected.
Known related issue, not fixed here
The new
rb_raiseunwinds pastZSTD_freeDCtx(dctx)inrb_decompress, so theZSTD_DCtxleaks. This is pre-existing — the existingZSTD_isErrorbranch a few lines above has the same problem, and libzstd may have already allocated a window buffer of up to ~128 MB by then. Wrapping the decode inrb_ensure(or wrapping the DCtx in a TypedData object) fixes all of these paths at once. I kept it out of this PR to keep the diff minimal, and I am glad to send it separately.🤖 Generated with Claude Code