Skip to content

Fix native memory corruption when frame stepping runs concurrently with seeks (ShowFrameNext/Prev vs Seek task) - #706

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

Fix native memory corruption when frame stepping runs concurrently with seeks (ShowFrameNext/Prev vs Seek task)#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

Summary

Running frame stepping (ShowFrameNext / ShowFramePrev / ShowFrame) while a seek's decoder work is still in flight corrupts native FFmpeg state. With 3+ Player instances stepping right after accurate seeks (a multi-angle review app), stock v3.10.4 crashes within seconds, 100% reproduciblyAccessViolationException in avcodec_send_packet or av_read_frame, heap corruption 0xC0000374, or fail-fast 0xC0000409. This PR fixes the two races behind it. Likely the root cause of #431 (rapid ShowFrameNext/SeekBackward jog-wheel crashes) and related to #457 (rapid seek crashes).

Both races are already acknowledged in the library's own comments — this isn't a speculative change:

VideoDecoder.cs: * Missing locks (e.g. GetFrameNext) ... Currently no issues as we use locks at higher level
DecoderContext.GetVideoFrame: // TBR: Between seek and GetVideoFrame lockCodecCtx is lost ... (Currently ensure you pause VideDecoder before seek)

The "locks at higher level" assumption doesn't hold: the seek task runs outside Player.lockActions.

Race 1 — seek task vs step path on the same AVCodecContext

Player.Seek (private) does its decoder work (decoder.PauseDecodersdecoder.Seekdecoder.GetVideoFrameavcodec_send_packet) on a Task.Run background task that holds no player-level lock. ShowFrame/ShowFrameNext/ShowFramePrev hold lockActions — which the seek task never takes — and call decoder.Flush() / VideoDecoder.GetFrame/GetFrameNext (which take no internal locks). Result: one thread flushes/decodes on the codec context while another is inside avcodec_send_packet on the same context → AV / heap corruption.

Fix: a new DecoderContext.StepSeekLock serializes the seek task's decoder section against the three step methods.

Why a new outermost lock instead of reusing the existing ones: the internal lock orders are already inverted between the two sides of the seek path — DecoderContext.Seek takes lockCodecCtxlockFmtCtx, while GetVideoFrame takes lockFmtCtxlockCodecCtx (safe today only because both run sequentially on the seek task). Composing either order into the step path from a different thread would introduce a lock-order deadlock. StepSeekLock is therefore always the outermost lock in both paths and never nests the existing locks in a new order.

SeekCompleted is deliberately raised outside the lock, so a subscriber that calls back into a lockActions-taking player API can't deadlock against a step holding lockActions while waiting for StepSeekLock.

Race 2 — demuxer thread vs GetFrameNext on the same fmtCtx (and the same packet field)

GetNextPacket documents "Demuxer must not be running", and GetFrame enforces it (demuxer.Pause(); Pause();) — but GetFrameNext never did. After any seek, VideoDemuxer.Start() leaves the demuxer thread filling queues; a subsequent ShowFrameNext that misses the vFrames queue calls GetFrameNextGetNextPacketav_read_frame(fmtCtx, packet) concurrently with the demuxer thread's own av_read_frame — on the same fmtCtx and writing into the demuxer's single packet field. This was the second crash signature (AV in av_read_frame inside Demuxer.RunInternal) that surfaced once Race 1 was fixed.

Fix: GetFrameNext now pauses demuxer + decoder first, exactly as GetFrame always did.

Reproduction

3–4 Player instances on the same local 1080p60 file, paused, then ShowFrameNext issued to all instances repeatedly right after SeekAccurate calls (the pattern of a multi-angle app stepping a synced group, or #431's 10 Hz jog wheel). Stock v3.10.4: crash within seconds, every run, with the signatures above. Two instances never reproduce it — at that load the seek task's decoder work completes in ~0.6 s, before the first step arrives; at four instances seeks take ~1.2 s and the windows stay open. The tile count only widens the race windows; the races themselves are per-instance.

Verification

With this patch, the previously 100%-crashing matrix passes: 3 and 4 instances, steps issued back-to-back and 100 ms-paced, plus stress bursts (60 rapid steps, alternating +1/-1, step/seek interleaving) — 23 probe runs (~1,400 native steps) and 3 stress runs, all clean, with frame-exact step landings verified by pixel comparison against a burned-in frame counter (round trips return to the exact tick and a 0-diff frame; all instances land the identical frame).

One honest caveat: a single unreproduced 0xC0000005 occurred in an early patched run (1 in ~26 runs; never again in 23 subsequent targeted attempts). If something remains, the render-frame lifetime path (vFrames dispose vs in-flight present) is the un-ruled-out suspect — this patch doesn't touch it. The two races fixed here account for every reproducible crash and both captured stacks.

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.

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.

1 participant