Skip to content

VideoReader.get_batch() deadlocks forever in native code on a clip whose container over-reports frame count (len(vr) = 431 but only 300 frames are decodable) #373

Description

@LukeLIN-web

Summary

On a specific H.264 clip, VideoReader.get_batch(idx) enters an infinite native loop (100% CPU on one core, never returns) when idx spans the whole file via forward seeks and ends at one of the last two nominal frame indices.

The root cause is a frame-count mismatch: len(VideoReader) trusts the container's nominal nb_frames (431 = duration 10.033s × 30 fps), but the file only has 300 actually-decodable frames. Indices computed from len(vr) therefore point past real EOF, and the decoder spins forever trying to reach a frame that does not exist.

Because the hang is in native C holding the GIL, it cannot be interrupted by Python signals / SIGALRM / thread timeouts — the only recovery is killing the process. This makes it especially nasty inside batch pipelines (e.g. Qwen3-Omni's qwen_omni_utils, which generates exactly these indices via torch.linspace(0, len-1, nframes)).

Environment

  • decord 0.6.0 (pip)
  • Python 3.11, Linux x86_64
  • system ffmpeg 4.4.2
  • Reproduces with no torch imported — pure decord. (Originally seen inside a torch process, but torch is a red herring; the trigger is the index pattern, not torch/OpenMP.)

The smoking gun

$ ffprobe -v error -select_streams v:0 \
    -show_entries stream=nb_frames,avg_frame_rate,duration -of default=nw=1 clip.mp4
duration=10.033000
avg_frame_rate=30/1
nb_frames=431                # <- nominal; decord's len(vr) returns this

$ ffprobe -v error -select_streams v:0 -count_frames \
    -show_entries stream=nb_read_frames -of default=nw=1 clip.mp4
nb_read_frames=300           # <- actually decodable frames

len(VideoReader(clip)) == 431, but only 300 frames really decode.

Minimal reproduction

import faulthandler, sys, time
from decord import VideoReader
faulthandler.dump_traceback_later(20, repeat=True)

vr = VideoReader(sys.argv[1])
print("len(vr):", len(vr))            # 431  (real decodable frames: 300)

idx = [0,16,32,48,64,80,96,111,127,143,159,175,191,207,
       223,239,255,271,287,303,319,334,350,366,382,398,414,430]
print("get_batch...", flush=True)
vr.get_batch(idx).asnumpy()           # hangs forever, 100% CPU, holds GIL
print("done")

Run it under an outer timeout so it can't freeze your shell:

timeout --signal=KILL 60 python minrepro.py clip.mp4

What hangs vs what works (bisection)

All of these were run as fresh processes on the same clip:

Access pattern Result
get_batch([0]), [207], [430] (any single frame) ✅ OK
get_batch(range(431)) (full contiguous read) ✅ OK, 2.1s
get_batch([0,16,…,414]) (27 strided, ends at 414) ✅ OK
get_batch([0,16,…,414,428]) (ends at 428) ✅ OK
get_batch([0,16,…,414,429]) (ends at 429) HANG
get_batch([0,16,…,414,430]) (ends at 430 = len-1) HANG
get_batch([366,382,398,414,430]) (short tail only) ✅ OK

So the deadlock requires both: a long forward-seeking batch across the file and a final index ≥ len-2 (429/430), i.e. past the 300 real frames. A single near-EOF read, or a short read, takes a different (clamping) path and is fine.

Native stack while hung (faulthandler)

Thread 0x... (most recent call first):
  File ".../decord/_ffi/_ctypes/function.py", line 173 in __call__
  File ".../decord/video_reader.py", line 175 in get_batch
  File "minrepro.py", line ... in <module>

100% CPU on one core, no progress; uninterruptible via Python signals because the GIL is held in the native call.

Expected behavior

Either:

  1. len(VideoReader) should reflect the number of decodable frames (not the container's nominal nb_frames), or
  2. get_batch() should clamp/raise on out-of-range indices instead of looping forever at EOF.

A bounded error would let callers skip the clip; an infinite native loop cannot be recovered without killing the process.

Workarounds (for others hitting this)

  • Switch video backend away from decord. For Qwen2.5-VL / Qwen3-Omni qwen_omni_utils: FORCE_QWENVL_VIDEO_READER=torchvision (or torchcodec).
  • Decode in a killable subprocess with a wall-clock timeout (only reliable way to interrupt a GIL-holding native hang).
  • Pre-validate clips with ffprobe -count_frames and clamp requested indices to the real frame count.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    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