Skip to content

Commit 4d1a0cc

Browse files
committed
fix(wgc): repair the four things the GPU path got wrong under review
Four defects CodeRabbit surfaced on #305, each verified against the code before being touched. One of them is worse than it was reported to be. **AcquireSync was tested with the wrong predicate, in both directions.** IDXGIKeyedMutex::AcquireSync reports a timeout as WAIT_TIMEOUT (0x102), a positive HRESULT that passes both SUCCEEDED() and !FAILED(). The capture side tested FAILED(), so a timeout fell straight through to CopyResource without ever holding the key, and only hard errors -- device removed, E_FAIL, WAIT_ABANDONED -- were caught, then misreported as ordinary contention, which skips the frame and retries forever on a bridge that can never work again. The encoder side used succeeded(), so a timeout there read as acquired. Both now test WAIT_TIMEOUT by value. This also means the "Contended frames: 0" figure in the PR description was measured with a counter that could not count: a timeout never reached it. The number says nothing either way and is being remeasured. **Inline webcam PiP would have silently lost its overlay.** useDxgiInput read webcamActive, which is only set once webcam capture starts -- long after the encoder is configured. The condition was therefore dead: always false, always permitting the GPU path. A webcamEnabled recording with no separate output would have run on DXGI, which cannot compose the overlay, and reported success. It now reads config.webcamEnabled, which is final at that point. **The stop breadcrumb named the wrong call.** encodeStage_ was stamped before writerMutex_ was taken, so a video thread queued behind an audio write reported "write-sample" while it was in fact blocked on the lock -- precisely the case the watchdog exists to distinguish, since an audio write is the only other thing that takes that mutex. It is now stamped inside the lock, and writeAudio names its own write instead of being anonymous. Both stamps are serialized by the mutex they sit under. **finalize() left Media Foundation objects for the destructor.** videoSampleAllocator_ and dxgiDeviceManager_ outlived MFShutdown(), and captureDevice_/captureContext_ kept the WGC device alive past the point main.cpp believes session.stop() releases it. finalize() now calls releaseDxgiPipeline() and drops the capture device before MFShutdown(). Not addressed here: bridge-texture creation still happens on the first frame, so a driver that refuses shared keyed-mutex textures fails the recording rather than degrading, which contradicts what three documents claim. That one is a restructure with a real trade-off attached and is being decided separately. Compile-verified in CI only; no hardware smoke test on this machine.
1 parent 976d44d commit 4d1a0cc

2 files changed

Lines changed: 60 additions & 7 deletions

File tree

electron/native/wgc-capture/src/main.cpp

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -612,9 +612,17 @@ int main(int argc, char* argv[]) {
612612
// produce. The env var is the escape hatch for a machine where the GPU path
613613
// misbehaves in a way the encoder's own probes do not catch -- a support
614614
// answer instead of a hotfix.
615+
//
616+
// config.webcamEnabled, not webcamActive: the latter is only set once the
617+
// webcam capture has started, which happens well after this. Reading it
618+
// here made the PiP condition dead code -- always false, so always
619+
// permitting the GPU path -- and an inline-PiP recording would have run on
620+
// DXGI and silently dropped the overlay, reporting success either way.
621+
// config.webcamEnabled is already cleared above when webcam init fails,
622+
// and writeSeparateWebcam is assigned there too, so both are final here.
615623
encoderOptions.useDxgiInput =
616624
!config.preferSoftwareEncoder &&
617-
(!webcamActive || writeSeparateWebcam) &&
625+
(!config.webcamEnabled || writeSeparateWebcam) &&
618626
readEnvInt("OPENSCREEN_WGC_DISABLE_DXGI_INPUT", 0) == 0;
619627

620628
MFEncoder encoder;

electron/native/wgc-capture/src/mf_encoder.cpp

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,11 +1029,25 @@ MFEncoder::Nv12ConvertResult MFEncoder::convertBgraTextureToNv12(
10291029
// Key 0 is the capture side's, key 1 the encoder's. Timing out here leaves
10301030
// key 0 exactly where it was, so the next frame simply tries again; that
10311031
// is the whole reason this one is recoverable and the one below is not.
1032+
//
1033+
// Tested by value, not with FAILED(): AcquireSync reports a timeout as
1034+
// WAIT_TIMEOUT (0x102), which is a *positive* HRESULT and therefore passes
1035+
// both SUCCEEDED() and !FAILED(). Testing FAILED() alone got this backwards
1036+
// in both directions -- a timeout fell through to the CopyResource below
1037+
// without ever holding the key, and a hard error (DXGI_ERROR_DEVICE_REMOVED,
1038+
// E_FAIL, WAIT_ABANDONED) was reported as ordinary contention, which skips
1039+
// the frame and retries forever on a bridge that can never work again. The
1040+
// recording then ends successfully with almost no frames in it.
10321041
encodeStage_ = "bridge-acquire-capture";
1033-
if (FAILED(captureBridgeMutex_->AcquireSync(0, acquireTimeoutMs))) {
1042+
const HRESULT captureAcquireHr = captureBridgeMutex_->AcquireSync(0, acquireTimeoutMs);
1043+
if (captureAcquireHr == static_cast<HRESULT>(WAIT_TIMEOUT)) {
10341044
encodeStage_ = "idle";
10351045
return Nv12ConvertResult::Contended;
10361046
}
1047+
if (!succeeded(captureAcquireHr, "Acquire capture bridge")) {
1048+
encodeStage_ = "idle";
1049+
return Nv12ConvertResult::Failed;
1050+
}
10371051
encodeStage_ = "bridge-copy";
10381052
captureContext_->CopyResource(captureBridgeTexture_.Get(), texture);
10391053
encodeStage_ = "bridge-release-capture";
@@ -1043,8 +1057,17 @@ MFEncoder::Nv12ConvertResult MFEncoder::convertBgraTextureToNv12(
10431057
encodeStage_ = "bridge-acquire-encoder";
10441058
// Key 1 was just handed over by this same thread and nothing else in the
10451059
// process can hold it, so a failure here means the bridge is broken rather
1046-
// than busy, and no later frame could recover it.
1047-
if (!succeeded(encoderBridgeMutex_->AcquireSync(1, acquireTimeoutMs), "Acquire encoder bridge")) {
1060+
// than busy, and no later frame could recover it. A timeout counts as broken
1061+
// for that same reason, and needs the same by-value test as above, since
1062+
// WAIT_TIMEOUT passes SUCCEEDED() and would otherwise be read as acquired.
1063+
const HRESULT encoderAcquireHr = encoderBridgeMutex_->AcquireSync(1, acquireTimeoutMs);
1064+
if (encoderAcquireHr == static_cast<HRESULT>(WAIT_TIMEOUT)) {
1065+
std::cerr << "ERROR: Acquire encoder bridge timed out" << std::endl;
1066+
encodeStage_ = "idle";
1067+
return Nv12ConvertResult::Failed;
1068+
}
1069+
if (!succeeded(encoderAcquireHr, "Acquire encoder bridge")) {
1070+
encodeStage_ = "idle";
10481071
return Nv12ConvertResult::Failed;
10491072
}
10501073
const auto releaseEncoderBridge = [&]() {
@@ -1268,12 +1291,16 @@ bool MFEncoder::submitVideoSample(IMFSample* sample) {
12681291
// encode synchronously on the calling thread. Callers must NOT hold any
12691292
// lock shared with a thread that needs to make timely progress (e.g. a
12701293
// stop-request check) across this call.
1271-
encodeStage_ = "write-sample";
1294+
// Stamped after the lock, not before it. The breadcrumb is meant to name the
1295+
// call the writer is *inside*; setting it first made "write-sample" also mean
1296+
// "queued behind writeAudio, which is inside WriteSample" -- the one case the
1297+
// watchdog most needs to tell apart, since an audio write is the only other
1298+
// thing that takes this mutex.
12721299
std::scoped_lock writerLock(writerMutex_);
12731300
if (!sinkWriter_ || finalized_) {
1274-
encodeStage_ = "idle";
12751301
return false;
12761302
}
1303+
encodeStage_ = "write-sample";
12771304
const bool written = succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample), "WriteSample");
12781305
encodeStage_ = "idle";
12791306
return written;
@@ -1317,7 +1344,14 @@ bool MFEncoder::writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampH
13171344
sample->SetSampleTime(std::max<int64_t>(0, timestampHns));
13181345
sample->SetSampleDuration(durationHns);
13191346

1320-
return succeeded(sinkWriter_->WriteSample(audioStreamIndex_, sample.Get()), "WriteSample(audio)");
1347+
// Named too, for the same reason the video write is: this is a synchronous
1348+
// encode holding writerMutex_, so it is a place the process can be stuck,
1349+
// and a watchdog report that only ever names video writes cannot say so.
1350+
encodeStage_ = "write-audio";
1351+
const bool written =
1352+
succeeded(sinkWriter_->WriteSample(audioStreamIndex_, sample.Get()), "WriteSample(audio)");
1353+
encodeStage_ = "idle";
1354+
return written;
13211355
}
13221356

13231357
bool MFEncoder::finalize() {
@@ -1333,6 +1367,17 @@ bool MFEncoder::finalize() {
13331367
sinkWriter_.Reset();
13341368
}
13351369
stagingTexture_.Reset();
1370+
// Before MFShutdown(), not left to the destructor. Two of the objects
1371+
// releaseDxgiPipeline() drops -- videoSampleAllocator_ and
1372+
// dxgiDeviceManager_ -- are Media Foundation objects, and releasing those
1373+
// after MFShutdown() has run is not something the platform promises
1374+
// anything about. The rest matters to the caller rather than to MF:
1375+
// captureDevice_/captureContext_ hold the WGC D3D11 device, so leaving them
1376+
// set means session.stop() in main.cpp is no longer dropping the last
1377+
// reference to the device it thinks it owns.
1378+
releaseDxgiPipeline();
1379+
captureContext_.Reset();
1380+
captureDevice_.Reset();
13361381
context_.Reset();
13371382
device_.Reset();
13381383
MFShutdown();

0 commit comments

Comments
 (0)