Fix two thread races in the frame-step paths (ShowFrameNext/Prev vs the Seek task; GetFrameNext vs the running demuxer) - #706
Conversation
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).
|
▎ 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. |
|
Hi @dderoia and thanks for the PR! |
What breaks: with three or more
Playerinstances open, frame-stepping (ShowFrameNext/ShowFramePrev) shortly after a seek crashes the process, typically within seconds. The crash is0xC0000005insideavcodec_send_packet, and it is reproducible on stock 3.10.4 essentially 100% of the time under that pattern. TwoPlayerinstances 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.
Player.Seek's background task vsShowFrameNext/ShowFramePrevon the sameAVCodecContext. The seek task runsdecoder.Seek/GetVideoFrameoutsidePlayer.lockActions, while the step paths callFlush/decode on the same codec context.DecoderContext.cs:510records 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.VideoDecoder.GetFrameNextvs the running demuxer on the samefmtCtx. 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 bothfmtCtxand its single packet field withGetNextPacket. 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:
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.GetFrameNextpause the demuxer and decoder before running, matching whatGetFramehas always done and satisfying its own documented precondition.Why
StepSeekLockis 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 andGetVideoFrame, so any attempt to nestlockActionsandlockCodecCtxconsistently 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
SeekCompletedmoved outside the lock. Firing it while holdingStepSeekLockdeadlocks any subscriber that calls back into alockActions-taking API — a frame step holdinglockActionswould be waiting onStepSeekLock, and the event handler would be waiting onlockActions. 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
Playerinstances 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:CurTimeechoed the requested position andSeekCompletedfired 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 newSendDrainAVPacketis needed becauseSendAVPacket(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 neitherCurTimenorSeekCompletedis 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.