Repository navigation
[Bug]: Windows 10 native recording can hang on stop and fail to save MP4 in 1.9.4-rc.3 #359
Description
Search existing issues
- I have searched the existing issues.
Related: #252, #336, #346 (and the earlier #34 / #115 stop-path reports).
Describe the bug
On the packaged OpenScreen 1.9.4-rc.3 app on Windows 10, ending a native Windows screen recording can fail to save the recording. The user sees a save/recording error after clicking Stop, the editor does not open with a usable recording, and the MP4 may never be finalized.
I reproduced and debugged this from the openscreen-release-v1.9.4 source archive on the affected machine. There were three distinct problems in the native helper path:
- Windows 10 startup crash:
session_.IsBorderRequired(false)was called through the projectedGraphicsCaptureSessionobject without first querying the optionalIGraphicsCaptureSession3interface. On Windows 10 19045 this could terminatewgc-capture.exewith0xC0000005; the surrounding C++ exception handler cannot catch a call through a missing ABI interface. - Stop/save hang: the DXGI input path created a second D3D11 encoding device and copied every frame through a shared texture guarded by
IDXGIKeyedMutex::AcquireSync. Under the full recording load on this machine, the display driver could fail to return from that cross-device synchronization. At the same time, application frame-state locking covered GPU copy/readback work. WGC callbacks then could not quiesce, the video thread could not join, andIMFSinkWriter::Finalizewas never reached, leaving the MP4 unsaved. - Packaged-app DPI compatibility: the installed
1.9.4-rc.3main process supplied the selected display as Electron DIP bounds (2560x1440at 150%), while the rebuilt per-monitor-v2-aware helper enumerated the physical monitor as3840x2160. Strict matching rejected the only monitor and the app fell back from native capture. This is closely related to wgc-capture.exe is DPI-unaware, and its monitor lookup silently depends on it #346.
The virtual display adapter and the discrete GPU may make the D3D synchronization failure more likely, but the blocking cross-device bridge and lock ownership make a driver stall fatal regardless of the trigger.
Expected behavior
Clicking Stop should quiesce WGC callbacks, join the writer threads, finalize the fragmented MP4, emit recording-stopped, and open the editor promptly. Optional Windows capture interfaces must not crash older Windows versions, and a single-monitor scaled setup must resolve the selected display without weakening multi-monitor correctness.
To Reproduce
- Run packaged OpenScreen
1.9.4-rc.3on Windows 10 22H2. - Select the 3840x2160 display at 150% Windows scaling.
- Enable system audio, microphone, webcam, and editable cursor capture.
- Record for several seconds.
- Click Stop.
- Observe that native capture can fail to stop/finalize and the application reports that the recording could not be saved.
The native-helper failure is below the React UI. Chromium MediaRecorder/WebM fallback saved correctly, but that only avoids the affected WGC helper path.
Root cause details
The relevant code is under electron/native/wgc-capture/src/.
wgc_session.cpp: direct use ofIsBorderRequiredassumesIGraphicsCaptureSession3is present.mf_encoder.cpp/.h: the DXGI pipeline owns a second D3D11 device plus a shared keyed-mutex bridge between capture and encoding devices.main.cpp: the WGC callback and video writer hold the frame-state mutex across D3DCopyResource, conversion, or staging readback work. A driver call that does not return prevents shutdown from draining the producer and joining the writer.monitor_utils.cpp: physical bounds are correct for the current helper, but an already packaged1.9.4-rc.3main process can still provide legacy DIP bounds.
The finite keyed-mutex timeout was not a sufficient safeguard on this machine: the driver-level synchronization could still fail to return. Because the writer was stranded before finalization, the user-visible result was a save failure rather than a recoverable dropped frame.
Fix implemented and tested locally
I rebuilt wgc-capture.exe with the following changes:
- Query
IGraphicsCaptureSession3withtry_asbefore settingIsBorderRequired; skip the cosmetic border setting when unavailable. - Remove the second D3D11 encoding device, the shared bridge textures, and both
IDXGIKeyedMutexhandshakes. - Reuse the WGC capture device and its multithread-protected immediate context for Media Foundation, BGRA-to-NV12 video processing, sample allocation, and hardware H.264 input.
- Create one stable BGRA frame texture before callbacks start. On the DXGI path it has
D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET. - Keep the frame-state mutex limited to timestamps and small state updates. Do
CopyResource, GPU conversion, CPU readback, and synchronous encoder submission outside that application mutex. D3D's multithread protection serializes the shared immediate context. - Prefer the repaired
dxgi-nv12path by default; retainOPENSCREEN_WGC_ENABLE_DXGI_INPUT=0as a diagnostic CPU fallback. - Preserve strict physical-bounds matching on multi-monitor systems. If and only if Windows enumerates exactly one monitor, accept legacy mismatched DIP bounds and select that sole monitor. This allows the rebuilt helper to work with the already packaged
1.9.4-rc.3main process without reintroducing wgc-capture.exe is DPI-unaware, and its monitor lookup silently depends on it #346's wrong-monitor guessing.
Test results
All tests below were performed on the affected machine with the rebuilt helper.
Native helper, default configuration, display + system audio + microphone
videoInput: dxgi-nv12- 351 video frames
wgc-quiesce drained=truerecording-stoppedemitted- Media Foundation finalize: approximately 169 ms
- Total stop: approximately 215 ms
- Exit code: 0
Repository full Windows helper test
Command: node scripts/test-windows-wgc-helper.mjs --webcam --system-audio --microphone
- Screen: H.264, 8.033 s, 8,014,121 bytes
- Audio: AAC, 7.872 s
- Webcam: H.264, 1920x1080, 8.033 s, 8,329,975 bytes
- Decoded first-frame mean brightness: 92.52 (not black)
- Stop through all outputs saved: approximately 971 ms
- Fragmented MP4 prefix readability check passed
- Exit code: 0
End-to-end development app test, forced native helper
- Log contains
[native-wgc] starting Windows captureand[native-wgc] capture started - Encoder selection:
default,videoInput: dxgi-nv12,container: fragmented-mp4 - Stop to editor: approximately 1.32 s
- Screen MP4: 3,818,300 bytes
- Webcam MP4: 8,020,193 bytes
- No Chromium/WebM fallback
End-to-end test of the actually installed packaged 1.9.4-rc.3 app
- Used the helper from the installed app resources, with display + system audio + microphone + webcam + editable cursor overlay
- Legacy
2560x1440DIP bounds safely resolved to the sole physical3840x2160monitor - Encoder selection:
default,videoInput: dxgi-nv12,container: fragmented-mp4 - Stop to editor: 1,369 ms
- Screen file: H.264 3840x2160 + AAC 48 kHz stereo, 8.0668 s, 6,947,583 bytes
- Separate webcam file: H.264 1280x720, 8.0667 s, 7,860,646 bytes
- Both files were non-empty and readable by
ffprobe - No native-capture fallback and no save error
npm run build-vite also passed. The native helper and cursor sampler rebuilt successfully with Visual Studio 2022 Build Tools.
Screenshots
The original user-facing error was displayed immediately after ending the recording and stated that the recording could not be saved. I can provide the screenshot separately if useful; it contains no additional native stack trace.
OS
Windows
OS Version
Windows 10 Pro 22H2, version 10.0.19045, build 19045, 64-bit
Browser
Other
Browser Version
Packaged Electron app (Electron 41.2.1)
Device Type
Desktop
Additional context
- OpenScreen:
1.9.4-rc.3(1.9.4.0product version) - CPU: AMD Ryzen 7 5700X, 8 cores / 16 logical processors
- Memory: 31.9 GiB
- Physical GPU: NVIDIA GeForce RTX 4060 Ti
- NVIDIA driver:
31.0.15.5152 - Additional display adapter: OrayIddDriver Device, driver
17.50.19.949 - Display: 3840x2160
- Windows scaling: 150% (
AppliedDPI=144) - Rebuilt helper SHA-256:
1234FA69D894E6AEFB805BFD0CC4E4D9AE042806FF95DE3784DB94AE226893C2 - Toolchain: Visual Studio 2022 Build Tools 17.10.5, Node 22.22.1, npm 10.9.4
No username, machine name, local paths, browser media device IDs, or recording content are included in this report.
Activity
EtienneLescot commentedon Aug 14, 2026
Thanks for the detailed trace.
Two of your three findings are already addressed on
release/v1.9.5(rc.2):fe794ff1made the DXGI input path opt-in, and it is not in1.9.4-rc.3 — which is why you got
videoInput: dxgi-nv12by default. rc.2defaults to
cpu-rgb32, so the second D3D11 device and the keyed-mutex bridgeare off the default path. Verified on a build here.
60bb6d7cconverts Electron DIPs to physical pixels beforethe rect reaches the helper. Your single-monitor shim was needed because a new
helper met the old rc.3 main process; in rc.2 both halves agree.
IsBorderRequired: which Windows SDK did you build against? With C++/WinRT2.0.250303.5 the projected setter expands to
try_as_with_reason<IGraphicsCaptureSession3>+check_hresult— it throwshresult_no_interfacerather than making an unchecked vtable call, and theexisting
catch (...)handles it. Older C++/WinRT emitted the unchecked form, sothis could be real against your SDK, but then it is a local-rebuild issue rather
than one in the shipped helper.
Can you run rc.2 as shipped, without your patch? Hang gone → your diagnosis is
confirmed. Hang persists → the DXGI bridge is ruled out and the search narrows.
That is more useful right now than merging a patch.
We won't restore
dxgi-nv12as the default: it is off because it cost thereporter of #336 their recording. Worth revisiting in 1.10 with your rewrite
reviewed.
slipperpeng commentedon Aug 14, 2026
Thanks. I tested the current
release/v1.9.5head without my patch. The result is the second branch of your diagnostic: the hang persists withcpu-rgb32, so the DXGI bridge is ruled out on this machine.Exact build tested
There is no public rc.2 installer/tag yet, so I used the repository's own Windows diagnostic artifact rather than another local rebuild:
release/v1.9.5atb1b81de582e5b801a58b1b3d3fdd33b7f86239b9openscreen-diagnostic-windows-x64wgc-capture.exeSHA-256:11800C8859F559641E76052E2852B4CFAAD368B32D31076034EA85F2E22CCC2EOPENSCREEN_WGC_ENABLE_DXGI_INPUTvariable was setThe helper reported:
{"video":"default","videoInput":"cpu-rgb32","container":"fragmented-mp4","preferSoftwareEncoder":false}SDK answer /
IsBorderRequiredMy local rebuild used:
10.0.22621.02.0.220110.517.10.5/ MSVC19.40.33813You were correct about the old projection. An unmodified local build of the same branch exits with
0xC0000005immediately after thecursor-captureevent, before recording starts orstopis sent. The official CI helper above does not crash there. I therefore considerIsBorderRequireda local old-SDK rebuild issue, separate from the shipped-helper stop hang.Default rc.2 result
The repository helper test failed 3/3 times with the official CI helper (5 s display capture, no audio). All three runs received
stop, then followed the same sequence:The raw diagnostic contains:
I also ran the bundled diagnostic tool for 10 seconds:
5003 ms,falsevideo-writer-joinat13030 ms13066 ms35009 ms,falsevideo-writer-joinat13600 ms13635 ms3The selected microphone in the second run was the real default endpoint. Audio changes the later timings slightly but is not required to reproduce the hang.
Two more controls fail in the same place:
That rules out the display-source type and the hardware H.264 encoder as necessary triggers. It does not yet isolate texture size.
CPU-path A/B
As a control, I ran my backed-up repaired helper with
OPENSCREEN_WGC_ENABLE_DXGI_INPUT=0, so it also reportedvideoInput: cpu-rgb32. It passed 3/3 times on the same machine and same test script:79 ms,140 ms,138 mswgc-quiesce -> video-writer-join -> encoder-finalize -> wgc-session-close1234FA69D894E6AEFB805BFD0CC4E4D9AE042806FF95DE3784DB94AE226893C2This means the successful result does not depend on my rewritten DXGI path being active.
The remaining difference that matches the timing is the frame-state lock scope. In current rc.2:
mutexacrossID3D11DeviceContext::CopyResource(main.cpparound 913-927);mutexacrosscaptureVideoSample(main.cpparound 961-1085);CopyResourceand blockingMap(D3D11_MAP_READ)(mf_encoder.cpparound 916-919).If that CPU readback stalls while the writer owns
mutex, an already-entered WGC callback waits on the same mutex.quiesceCapture()then cannot drain that callback, and joining the writer cannot finish. Also,encode_stage=idleappears not to cover the CPUcopyFrameToBufferpath, so it does not rule outCopyResource/Map; the stage breadcrumbs are set on the DXGI conversion path.My repaired helper moves the D3D copy/readback work outside the application frame-state mutex and relies on D3D multithread protection for the shared immediate context. The successful forced-CPU A/B makes that lock-scope change the leading explanation now, rather than the keyed-mutex bridge.
EtienneLescot commentedon Aug 29, 2026
@slipperpeng — sorry for the long silence on this. Your 14 Aug trace is the reason I have come back to it, and it deserved an answer a lot sooner than two weeks.
What your trace decided
On 11 Aug, before either of us had that measurement, I wrote in #252 that
encode_stage=idleis what #306 predicts and #305 does not. You then produced it 3/3 on the official CI helper withvideoInput: cpu-rgb32— the default path, no DXGI bridge in play. @Afton-programmer's Save Diagnostics report on #460 landed on the same place from the other direction:videoEncoderRuntime: hardware, and the callback still stuck.So the encoder is out and the bridge is out. What is left is the thing neither of the merged fixes touches:
onFrameArrivedholds the shared frame-state lock acrossCopyResource, on a thread we do not own, and when that call does not return the lock is gone for good.There is now a build that removes that thread
#306 (pull-based frame delivery, by @abduznik) had been sitting conflicted since 11 Aug. It is rebased onto
mainand mergeable again.writeVideoFramespulls each frame itself withTryGetNextFrame()and does the copy on its own thread; noFrameArrivedhandler is registered at all by default.Same delivery mechanism you used last time, so nothing to compile:
openscreen-diagnostic-windows-x64(expires 12 Sep; GitHub requires you to be signed in to download it)The two runs that would settle it
Exactly what you already did, then the same bundle a second time with one variable set:
That flag restores the shipped push-based path from the same binary, so the two runs differ in one thing only. The
wgc-quiesceline now names which one ran:mode=pull,drained=true, stop completesmode=pull,drained=true,video-writer-join … phase=abandonedmode=legacy-callback,drained=false,reason=frame-callback-stuckThe middle row is the one I am actually asking for, and it is the reason I would rather you ran this than merge on the strength of a hypothesis that has never met the hardware it is about. On mine — Windows 11, integrated Radeon, no virtual display — all six scenarios pass and always did; it has never reproduced this bug and cannot falsify anything.
On
IsBorderRequired: I took your conclusion, local old-SDK rebuild rather than the shipped helper, and there is nothing outstanding for you there.No rush and no obligation. This has already cost you a lot of runs, and the diagnosis in your first report is what got the rest of us pointed the right way.
abduznik commentedon Aug 29, 2026
@EtienneLescot I know your message was directed at @slipperpeng, but I went ahead and ran the diagnostic build across both modes on my own setup (AMD Ryzen 5 4500 + RTX 4060 Ti, Windows 10 Pro, standard cpu-rgb32 software frame copy + hardware video encoder pipeline).
The results align directly with Row 1 vs Row 3:
[stop-timing] step=wgc-quiesce elapsed_ms=0 drained=true mode=pull
[stop-timing] step=video-writer-join elapsed_ms=0 phase=begin
[pacing] frames=284 elapsed_ms=9480
[stop-timing] step=video-writer-join elapsed_ms=0
Status: Clean exit (exitCode: 0)
Stop Duration: stopElapsedMs dropped to 84 ms (down from ~13,000 ms)
Writer Join: video-writer-join completed cleanly in 0 ms
WARNING: A WGC frame callback did not finish; leaving the device alive
[stop-timing] step=wgc-quiesce elapsed_ms=5012 drained=false mode=legacy-callback
[stop-timing] step=video-writer-join elapsed_ms=5012 phase=abandoned encode_stage=idle audio_stage=idle reason=frame-callback-stuck
Status: Timed out (exitCode: 3, stop-timeout on video-writer-join)
Stop Duration: stopElapsedMs hit 5057 ms before abandoning
Quiesce: drained=false, reason=frame-callback-stuck
Conclusion
The control flag cleanly reproduces the original callback deadlock on my hardware, while pull mode (mode=pull) completes teardown almost instantaneously with zero wedging. Moving CopyResource off the OS-managed WGC callback thread and pulling via TryGetNextFrame() on the writer thread resolves the deadlock as hypothesized. #306 is confirmed as the fix.
slipperpeng commentedon Sep 1, 2026
@EtienneLescot I ran the requested A/B test on the original Windows 10 machine with the same diagnostic build and the same inputs: 10-second display capture with system audio and microphone enabled.
Build:
fix/wgc-pull-based-frame-deliveryatf4cafb2from Actions run 33259506707.Default mode: pull
0141 msrecording-stoppedemittedControl mode: legacy callback
The only change was setting:
35801 msrecording-stoppednot emittedThe results match your table exactly. Pull mode completes and saves; legacy callback mode reproduces the callback deadlock. On this hardware, #306 is confirmed as the fix for the Issue #359 stop/save failure.
Test system: Windows 10 Pro 22H2 (build 19045), AMD Ryzen 7 5700X, NVIDIA GeForce RTX 4060 Ti.
EtienneLescot commentedon Sep 4, 2026
Closed by #306.
Huge thanks to @slipperpeng for the initial root-cause analysis and for running the decisive A/B verification on the original reproducing hardware, as well as @abduznik for the implementation and matching test run.
Both A/B passes confirmed the hypothesis:
mode=pull) completes teardown cleanly in ~80–140 ms with a valid, fully playable MP4.OPENSCREEN_WGC_LEGACY_FRAME_CALLBACK=1) cleanly reproduced the legacy callback deadlock (drained=false,reason=frame-callback-stuck).#306 is now merged into
main. TheOPENSCREEN_WGC_LEGACY_FRAME_CALLBACK=1escape hatch remains available in the binary as a rollback lever should any unexpected hardware edge cases arise.