From da61792de556354f4357b4d09a027647328c426f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 19 Jul 2026 09:09:20 +0200 Subject: [PATCH] fix(wgc): stop holding the frame mutex across blocking WriteSample calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The video-writer thread held the shared frame-state mutex across IMFSinkWriter::WriteSample, which the main thread's stop-wait also locks to check control.stopRequested. With the software H.264 encoder fallback (preferSoftwareEncoder: true), WriteSample is slow enough that the writer thread could keep re-acquiring the lock every frame faster than the main thread could win it after a stop request, starving the stop-wait indefinitely — explaining why no [stop-timing] line ever printed. Split MFEncoder::writeFrame/writeBgraFrame into a capture step (GPU readback + IMFSample construction, still under the shared mutex since it touches latestFrameTexture) and a submit step (WriteSample, only under the encoder's own low-contention writerMutex_), and call submit outside the shared mutex in main.cpp's writeVideoFrames. Fixes #115. Co-Authored-By: Claude Sonnet 5 --- electron/native/wgc-capture/src/main.cpp | 55 +++++++++-- .../native/wgc-capture/src/mf_encoder.cpp | 98 +++++++++++++------ electron/native/wgc-capture/src/mf_encoder.h | 21 +++- 3 files changed, 134 insertions(+), 40 deletions(-) diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 30a3069d6..b0bc3a382 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -606,6 +606,11 @@ int main(int argc, char* argv[]) { int64_t lastEncodedVideoTimestampHns = -1; while (!control.stopRequested && !encodeFailed) { + Microsoft::WRL::ComPtr videoSample; + Microsoft::WRL::ComPtr webcamSample; + bool hasVideoSample = false; + bool hasWebcamSample = false; + { std::unique_lock lock(mutex); control.cv.wait(lock, [&] { @@ -653,29 +658,59 @@ int main(int argc, char* argv[]) { latestWebcamSequence != lastWrittenWebcamSequence) { const int64_t webcamTimestampHns = static_cast( (webcamOutputFrameIndex * 10'000'000ULL) / std::max(1, webcamCapture.fps())); - if (!webcamEncoder.writeBgraFrame(webcamFrame, webcamTimestampHns)) { + hasWebcamSample = webcamEncoder.captureBgraSample(webcamFrame, webcamTimestampHns, webcamSample); + if (!hasWebcamSample) { encodeFailed = true; control.stopRequested = true; control.cv.notify_all(); - return; + break; } lastWrittenWebcamSequence = latestWebcamSequence; webcamOutputFrameIndex += 1; } - if (latestFrameTexture && !encoder.writeFrame( + if (latestFrameTexture) { + // captureVideoSample performs the GPU readback + // (CopyResource/Map) from latestFrameTexture, which must + // stay serialized (via `mutex`) against the WGC + // frame-arrival callback above, which writes new data + // into the same texture on another thread. + hasVideoSample = encoder.captureVideoSample( latestFrameTexture.Get(), frameTimestampHns, - !writeSeparateWebcam && webcamFrame.data ? &webcamFrame : nullptr)) { - encodeFailed = true; - control.stopRequested = true; - control.cv.notify_all(); - return; - } - if (latestFrameTexture) { + !writeSeparateWebcam && webcamFrame.data ? &webcamFrame : nullptr, + videoSample); + if (!hasVideoSample) { + encodeFailed = true; + control.stopRequested = true; + control.cv.notify_all(); + break; + } lastEncodedVideoTimestampHns = frameTimestampHns; } } + // Submit the captured samples to their sink writers OUTSIDE + // `mutex`. IMFSinkWriter::WriteSample runs the H.264 encode + // synchronously and can be slow (especially the software encoder + // fallback used when preferSoftwareEncoder is set). Holding + // `mutex` across it would block the main thread's stop-wait + // (which locks the same mutex to check control.stopRequested) + // for as long as this thread keeps re-acquiring the lock faster + // than the main thread can, hanging the helper indefinitely + // after a stop request (issue #115). + if (hasWebcamSample && !webcamEncoder.submitVideoSample(webcamSample.Get())) { + encodeFailed = true; + control.stopRequested = true; + control.cv.notify_all(); + break; + } + if (hasVideoSample && !encoder.submitVideoSample(videoSample.Get())) { + encodeFailed = true; + control.stopRequested = true; + control.cv.notify_all(); + break; + } + frameIndex += 1; std::this_thread::sleep_for(frameDuration); } diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 42b708910..60f82e9f5 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -603,23 +603,38 @@ bool MFEncoder::copyBgraFrameToBuffer(const BgraFrameView& frame, BYTE* destinat return true; } -bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame) { - std::scoped_lock writerLock(writerMutex_); - if (!sinkWriter_ || finalized_) { - return false; - } +bool MFEncoder::captureVideoSample( + ID3D11Texture2D* texture, + int64_t timestampHns, + const BgraFrameView* webcamFrame, + Microsoft::WRL::ComPtr& outSample) { + outSample.Reset(); - if (firstTimestampHns_ < 0) { - firstTimestampHns_ = timestampHns; - } + const int64_t sampleDuration = 10'000'000LL / fps_; + int64_t sampleTime = 0; + { + std::scoped_lock writerLock(writerMutex_); + if (!sinkWriter_ || finalized_) { + return false; + } - int64_t sampleTime = timestampHns - firstTimestampHns_; - if (sampleTime <= lastTimestampHns_) { - sampleTime = lastTimestampHns_ + (10'000'000LL / fps_); + if (firstTimestampHns_ < 0) { + firstTimestampHns_ = timestampHns; + } + + sampleTime = timestampHns - firstTimestampHns_; + if (sampleTime <= lastTimestampHns_) { + sampleTime = lastTimestampHns_ + sampleDuration; + } + lastTimestampHns_ = sampleTime; } - const int64_t sampleDuration = 10'000'000LL / fps_; - lastTimestampHns_ = sampleTime; + // The GPU readback below (copyFrameToBuffer -> CopyResource/Map on + // `texture`) is not internally synchronized here. Callers must hold their + // own lock around this call that also serializes against whatever thread + // writes new frame data into `texture` (see main.cpp's writeVideoFrames, + // which holds the shared frame-state mutex across this call but not + // across submitVideoSample). Microsoft::WRL::ComPtr buffer; const DWORD frameBytes = static_cast(width_ * height_ * 4); if (!succeeded(MFCreateMemoryBuffer(frameBytes, &buffer), "MFCreateMemoryBuffer")) { @@ -648,25 +663,34 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const sample->SetSampleTime(sampleTime); sample->SetSampleDuration(sampleDuration); - return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample"); + outSample = sample; + return true; } -bool MFEncoder::writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns) { - std::scoped_lock writerLock(writerMutex_); - if (!sinkWriter_ || finalized_) { - return false; - } +bool MFEncoder::captureBgraSample( + const BgraFrameView& frame, + int64_t timestampHns, + Microsoft::WRL::ComPtr& outSample) { + outSample.Reset(); - if (firstTimestampHns_ < 0) { - firstTimestampHns_ = timestampHns; - } + const int64_t sampleDuration = 10'000'000LL / fps_; + int64_t sampleTime = 0; + { + std::scoped_lock writerLock(writerMutex_); + if (!sinkWriter_ || finalized_) { + return false; + } - int64_t sampleTime = timestampHns - firstTimestampHns_; - if (sampleTime <= lastTimestampHns_) { - sampleTime = lastTimestampHns_ + (10'000'000LL / fps_); + if (firstTimestampHns_ < 0) { + firstTimestampHns_ = timestampHns; + } + + sampleTime = timestampHns - firstTimestampHns_; + if (sampleTime <= lastTimestampHns_) { + sampleTime = lastTimestampHns_ + sampleDuration; + } + lastTimestampHns_ = sampleTime; } - const int64_t sampleDuration = 10'000'000LL / fps_; - lastTimestampHns_ = sampleTime; Microsoft::WRL::ComPtr buffer; const DWORD frameBytes = static_cast(width_ * height_ * 4); @@ -696,7 +720,25 @@ bool MFEncoder::writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns) sample->SetSampleTime(sampleTime); sample->SetSampleDuration(sampleDuration); - return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample(webcam)"); + outSample = sample; + return true; +} + +bool MFEncoder::submitVideoSample(IMFSample* sample) { + if (!sample) { + return false; + } + + // This is the potentially slow, blocking step (especially with the + // software H.264 encoder fallback): IMFSinkWriter::WriteSample runs the + // encode synchronously on the calling thread. Callers must NOT hold any + // lock shared with a thread that needs to make timely progress (e.g. a + // stop-request check) across this call. + std::scoped_lock writerLock(writerMutex_); + if (!sinkWriter_ || finalized_) { + return false; + } + return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample), "WriteSample"); } bool MFEncoder::writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) { diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h index 8f5787481..e5fbd74c8 100644 --- a/electron/native/wgc-capture/src/mf_encoder.h +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -53,8 +53,25 @@ class MFEncoder { ID3D11DeviceContext* context, const AudioInputFormat* audioFormat = nullptr, MFEncoderOptions options = {}); - bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame = nullptr); - bool writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns); + // Capturing a video/webcam sample (GPU readback + IMFSample creation) is + // split from submitting it to the sink writer (IMFSinkWriter::WriteSample) + // so callers that hold an external lock across the GPU-touching capture + // step (to serialize against a producer thread writing into the same + // texture) are not forced to also hold that lock across the potentially + // slow, blocking WriteSample call. See main.cpp's writeVideoFrames for why + // this split exists: holding the shared frame-state mutex across + // WriteSample let the software H.264 encoder path starve the main + // thread's stop-request check indefinitely (issue #115). + bool captureVideoSample( + ID3D11Texture2D* texture, + int64_t timestampHns, + const BgraFrameView* webcamFrame, + Microsoft::WRL::ComPtr& outSample); + bool captureBgraSample( + const BgraFrameView& frame, + int64_t timestampHns, + Microsoft::WRL::ComPtr& outSample); + bool submitVideoSample(IMFSample* sample); bool writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns); bool finalize(); const char* videoEncoderSelection() const;