Skip to content

Fix two thread races in the frame-step paths (ShowFrameNext/Prev vs the Seek task; GetFrameNext vs the running demuxer) - #706

Closed
dderoia wants to merge 2 commits into
SuRGeoNix:masterfrom
dderoia:stepfix-3.10.4
Closed

Fix two thread races in the frame-step paths (ShowFrameNext/Prev vs the Seek task; GetFrameNext vs the running demuxer)#706
dderoia wants to merge 2 commits into
SuRGeoNix:masterfrom
dderoia:stepfix-3.10.4

Conversation

@dderoia

@dderoia dderoia commented Jul 20, 2026

Copy link
Copy Markdown

What breaks: with three or more Player instances open, frame-stepping (ShowFrameNext/ShowFramePrev) shortly after a seek crashes the process, typically within seconds. The crash is 0xC0000005 inside avcodec_send_packet, and it is reproducible on stock 3.10.4 essentially 100% of the time under that pattern. Two Player instances never reproduced it.

Why: two paths touch native FFmpeg state without a common lock. Both are already flagged in the library's own TBR comments.

  1. Player.Seek's background task vs ShowFrameNext/ShowFramePrev on the same AVCodecContext. The seek task runs decoder.Seek / GetVideoFrame outside Player.lockActions, while the step paths call Flush/decode on the same codec context. DecoderContext.cs:510 records the gap: "TBR: Between seek and GetVideoFrame lockCodecCtx is lost and if VideoDecoder is running will already have decoded some frames (Currently ensure you pause VideDecoder before seek)". The step paths do not pause the decoder, so the precondition is not met.

  2. VideoDecoder.GetFrameNext vs the running demuxer on the same fmtCtx. Its own summary states the precondition — "Gets next VideoFrame (Decoder/Demuxer must not be running)" (VideoDecoder.cs:1103) — but it never enforces it, and the demuxer thread shares both fmtCtx and its single packet field with GetNextPacket. The file header names this directly: "Missing locks (e.g. GetFrameNext) / Missing checks after locks (e.g. disposed) / Mixing locks (actions/demuxer/codecCtx/renderer)" (VideoDecoder.cs:13).

What the patch does:

  1. Adds DecoderContext.StepSeekLock, taken by both the seek task and the frame-step paths, so decoder work from a seek can no longer interleave with a step on the same codec context.
  2. Makes GetFrameNext pause the demuxer and decoder before running, matching what GetFrame has always done and satisfying its own documented precondition.

Why StepSeekLock is a new outermost lock rather than reusing the existing ones. Composing the existing locks is not possible without deadlocking: upstream's lock acquisition orders are already inverted between the seek path and GetVideoFrame, so any attempt to nest lockActions and lockCodecCtx consistently across both deadlocks one side or the other. A separate outermost lock, acquired before either, is the smallest change that serialises the two paths without reordering existing acquisitions.

Why SeekCompleted moved outside the lock. Firing it while holding StepSeekLock deadlocks any subscriber that calls back into a lockActions-taking API — a frame step holding lockActions would be waiting on StepSeekLock, and the event handler would be waiting on lockActions. The event now fires after the lock is released; the completed-ms value is captured inside and raised outside.

Verification. With the patch, the full step/seek matrix at 3 and 4 concurrent Player instances runs clean, including sustained stress: repeated native steps in both directions, steps immediately following seeks, and interleaved seek bursts. Round-trip accuracy is unchanged — +30/-30 step round trips return to the exact tick with pixel-identical frames, and every tile rests on its frame grid after 60 native steps. Step latency is unchanged within measurement noise.


Second commit — unrelated fix, eofdrain. GetVideoFrame (the accurate-seek path) returned at demuxer EOF without draining the codec's delay pipeline, where the stream's final frame always sits. The effect is that an accurate seek to end-of-stream could never present the last frame: CurTime echoed the requested position and SeekCompleted fired with the requested ms, but nothing was shown. It now sends a drain packet and receives the tail under the same half-frame acceptance test the frame-step path already uses (DecodeFrameNext). A new SendDrainAVPacket is needed because SendAVPacket(null) null-derefs its key-packet check.

Verified by pixel comparison against frames extracted independently with ffmpeg -ss <pos> -frames:v 1: ~44-46 dB PSNR for a correct final frame, ~10 dB for a wrong one. Note that neither CurTime nor SeekCompleted is trustworthy evidence on this path — both report success for a seek that presented nothing — so the fix was validated on pixels rather than on engine-reported state.


Happy to split the two commits into separate PRs if that is easier to review, or to adjust the locking approach if there is a composition you would prefer.

dderoia added 2 commits July 20, 2026 12:24
Two races, both admitted by the in-source TBR notes ('Missing locks (e.g.
GetFrameNext)' / 'Currently ensure you pause VideoDecoder before seek'):

1. Player.Seek's background task runs decoder.Seek + GetVideoFrame OUTSIDE
   Player.lockActions, so ShowFrame/ShowFrameNext/ShowFramePrev (holding
   lockActions) could Flush/decode on the same AVCodecContext concurrently
   with the seek task's avcodec_send_packet. New DecoderContext.StepSeekLock
   serializes both paths; always outermost, so the pre-existing inverted
   internal lock orders (Seek: codecCtx->fmtCtx, GetVideoFrame:
   fmtCtx->codecCtx) are never composed across threads. SeekCompleted now
   fires outside the lock (subscriber re-entrancy).

2. VideoDecoder.GetFrameNext documented 'Decoder/Demuxer must not be
   running' but never enforced it: after a seek, VideoDemuxer.Start() leaves
   the demuxer thread filling queues, sharing fmtCtx AND the demuxer's
   single packet field with GetNextPacket. GetFrameNext now pauses both,
   exactly as GetFrame always did.

Repro (before): 3-4 Player instances rapid-stepping right after accurate
seeks died within seconds - AV in avcodec_send_packet or av_read_frame,
heap corruption 0xC0000374, fail-fast 0xC0000409. After: clean runs at 3
and 4 instances, frame-exact round trips, pixel-identical cross-instance.
GetVideoFrame (the accurate-seek path) returned as soon as av_read_frame hit
EOF, leaving the codec's delay pipeline undrained - so the stream's FINAL
frame (and, depending on the target's half-frame acceptance window, the last
few frames) could never be presented by an accurate seek. A seek targeting
the end of the stream presented NOTHING while CurTime echoed the request and
SeekCompleted fired with the requested ms.

The frame-step path already drains (DecodeFrameNext); this gives the seek
path the same ability: at demuxer EOF, send the drain packet
(avcodec_send_packet with null - a new SendDrainAVPacket, bypassing
SendAVPacket whose key-packet validation dereferences the packet) and
receive the remaining frames under GetVideoFrame's own acceptance test. The
caller's lockFmtCtx + lockCodecCtx are already held; the next Flush/Seek's
avcodec_flush_buffers resets draining state as it always has.

Verified against real footage (25fps split outputs with non-zero video
start times and a 60fps recording): end-of-stream accurate seeks now
present the true final frame (native-resolution snapshots match
ffmpeg-extracted last frames at 44-46dB PSNR; previously the picture kept
the pre-seek frame).
@dderoia

dderoia commented Jul 24, 2026

Copy link
Copy Markdown
Author

▎ Added: a second fix in the same seek path (commit 836add3). GetVideoFrame (the accurate-seek path) returns as soon as av_read_frame hits EOF, leaving the codec's delay pipeline undrained — so an accurate seek targeting a stream's final frame presents nothing, while CurTime and SeekCompleted both report success. The frame-step path already drains at EOF (DecodeFrameNext); this gives the seek path the same ability via a null avcodec_send_packet, receiving the tail under GetVideoFrame's existing half-frame acceptance test. Reproduction: SeekAccurate to the last frame of any file — the picture never changes. Verified on 25 fps and 60 fps content by comparing rendered output against ffmpeg-extracted final frames.

@dderoia dderoia changed the title Fix native memory corruption when frame stepping runs concurrently with seeks (ShowFrameNext/Prev vs Seek task) Fix two thread races in the frame-step paths (ShowFrameNext/Prev vs the Seek task; GetFrameNext vs the running demuxer) Aug 13, 2026
SuRGeoNix added a commit that referenced this pull request Aug 15, 2026
…lated #706]

- Player: Fixes a locking issue betweek Seek and Frame Stepping [Related #706]

- Player: Fixes a possible deadlock during Disposal [Fixes #708]
@SuRGeoNix

Copy link
Copy Markdown
Owner

Hi @dderoia and thanks for the PR!
I've a new commit that I've included both fixes, feel free to check and let me know if I miss something.

@SuRGeoNix SuRGeoNix closed this Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants