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)
- Dominant language
- C++
- Stars
- 2.5k
- Forks
- 232
- PR merge metrics
- No merged PRs in 30d
Description
## 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
```python
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
```
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.
```
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.