From ca46e768df927d7ea3d0dc8d62bf6158b1a47112 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 10 Aug 2026 19:02:11 +0200 Subject: [PATCH 1/3] feat(wgc): report which GPU capture landed on, and let the tool test audio Neither #252 nor #327 has been reproduced on hardware we control, and the last two reports arrived with no OS, no version and no trace. Two additions, neither of which changes recording behaviour, so that the next report answers a question instead of opening another round of guessing. **Which adapter.** WgcSession creates its device with D3D11CreateDevice(nullptr, ...) -- the default adapter -- and nothing anywhere asks whether that is the adapter driving the display being captured. On one GPU the question does not arise. On a hybrid laptop, a discrete card, or a machine with virtual display adapters, the two can differ, and then every WGC frame has crossed an adapter boundary before the caller touches it: driver work on both GPUs, and the most plausible remaining candidate for these hangs. The helper now emits {"event":"capture-adapter","deviceAdapter":"...","monitorAdapter":"...", "sameAdapter":false} comparing by LUID rather than by description, since two cards of the same model report the same string. monitorAdapter is null when no DXGI output claims the monitor, which is itself worth seeing: on the #252 reporter's machine that would mean a virtual display adapter. Every failure inside the report is silent -- a diagnostic that can end a recording is worse than no diagnostic. **Audio in the diagnostic tool.** captureSystemAudio and captureMic were hardcoded false, so the standalone tool could not exercise the configuration that fails most consistently in #252. Audio and video writes contend for the same sink-writer lock, so a run without audio has nothing to contend with and passes on machines where the app hangs every time -- which is why no clean trace of that case exists. Now --system-audio and --mic, with the reasoning in both the tool's help and the README so a reporter knows to re-run with them. Verified: --system-audio produces an AAC 48 kHz stereo track alongside the video and stops in 152 ms on this machine. The C++ half is compile-verified in CI only; this machine has a single adapter, so it can only ever print sameAdapter=true and cannot exercise the interesting branch. --- electron/native/wgc-capture/src/main.cpp | 95 ++++++++++++++++++++++++ scripts/diagnostic-tool/README.md | 7 ++ scripts/diagnostic-tool/diagnostic.mjs | 20 ++++- 3 files changed, 119 insertions(+), 3 deletions(-) diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index fe2f8505..1b180aa9 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -195,6 +195,94 @@ std::string jsonEscape(const std::string& value) { return result; } +// Reports which GPU the capture device landed on, and which one actually drives +// the monitor being captured. +// +// WgcSession creates its device with D3D11CreateDevice(nullptr, ...) -- the +// default adapter -- and nothing anywhere asks whether that is the adapter that +// owns the target display. On a single-GPU machine the question does not arise. +// On a hybrid laptop, a machine with a discrete card, or one with virtual +// display adapters, the two can differ, and then every frame WGC delivers has +// crossed an adapter boundary before the caller ever touches it. That crossing +// is driver work on both GPUs, and it is the most plausible remaining candidate +// for the stop hangs in #252 / #327, which nobody has reproduced on hardware we +// control. +// +// This does not change behaviour, and deliberately so: it turns the next bug +// report into evidence instead of another round of guessing. Failures here are +// silent -- a diagnostic that can abort a recording is worse than no diagnostic. +void reportCaptureAdapters(ID3D11Device* device, HMONITOR targetMonitor) { + if (!device) { + return; + } + + Microsoft::WRL::ComPtr dxgiDevice; + if (FAILED(device->QueryInterface(IID_PPV_ARGS(&dxgiDevice)))) { + return; + } + Microsoft::WRL::ComPtr deviceAdapter; + if (FAILED(dxgiDevice->GetAdapter(&deviceAdapter))) { + return; + } + DXGI_ADAPTER_DESC deviceDesc{}; + if (FAILED(deviceAdapter->GetDesc(&deviceDesc))) { + return; + } + + // The device's own adapter knows its factory, so there is no need to create + // one (and no second code path to keep alive if that ever needs a flag). + Microsoft::WRL::ComPtr factory; + if (FAILED(deviceAdapter->GetParent(IID_PPV_ARGS(&factory)))) { + return; + } + + std::wstring monitorAdapterName; + bool monitorAdapterFound = false; + bool sameAdapter = false; + for (UINT adapterIndex = 0;; ++adapterIndex) { + Microsoft::WRL::ComPtr adapter; + if (factory->EnumAdapters1(adapterIndex, &adapter) == DXGI_ERROR_NOT_FOUND) { + break; + } + for (UINT outputIndex = 0;; ++outputIndex) { + Microsoft::WRL::ComPtr output; + if (adapter->EnumOutputs(outputIndex, &output) == DXGI_ERROR_NOT_FOUND) { + break; + } + DXGI_OUTPUT_DESC outputDesc{}; + if (FAILED(output->GetDesc(&outputDesc)) || outputDesc.Monitor != targetMonitor) { + continue; + } + DXGI_ADAPTER_DESC1 adapterDesc{}; + if (FAILED(adapter->GetDesc1(&adapterDesc))) { + continue; + } + monitorAdapterName = adapterDesc.Description; + monitorAdapterFound = true; + // Compared by LUID rather than by description, because two adapters + // of the same model report the same string. + sameAdapter = adapterDesc.AdapterLuid.LowPart == deviceDesc.AdapterLuid.LowPart && + adapterDesc.AdapterLuid.HighPart == deviceDesc.AdapterLuid.HighPart; + } + if (monitorAdapterFound) { + break; + } + } + + std::cout << "{\"event\":\"capture-adapter\",\"schemaVersion\":2,\"deviceAdapter\":\"" + << jsonEscape(wideToUtf8(deviceDesc.Description)) << "\",\"monitorAdapter\":"; + if (monitorAdapterFound) { + std::cout << "\"" << jsonEscape(wideToUtf8(monitorAdapterName)) << "\",\"sameAdapter\":" + << (sameAdapter ? "true" : "false"); + } else { + // No output claims this monitor: it is driven by something DXGI does not + // enumerate, which on the machines in #252 means a virtual display + // adapter. Worth seeing in a report in its own right. + std::cout << "null,\"sameAdapter\":null"; + } + std::cout << "}" << std::endl; +} + bool hasVisibleBgraContent(const std::vector& frame) { if (frame.size() < 4) { return false; @@ -489,6 +577,7 @@ int main(int argc, char* argv[]) { std::cout << "{\"event\":\"ready\",\"schemaVersion\":2}" << std::endl; WgcSession session; + HMONITOR capturedMonitor = nullptr; if (config.sourceType == "display") { HMONITOR monitor = findMonitorForCapture( config.displayId, @@ -497,6 +586,7 @@ int main(int argc, char* argv[]) { std::cerr << "ERROR: Could not resolve monitor" << std::endl; return 1; } + capturedMonitor = monitor; if (!session.initialize(monitor, config.fps, config.captureCursor)) { std::cerr << "ERROR: Failed to initialize WGC display session" << std::endl; return 1; @@ -507,6 +597,9 @@ int main(int argc, char* argv[]) { std::cerr << "ERROR: Native window capture requires a valid HWND" << std::endl; return 1; } + // A window is captured by whichever display it currently sits on, which + // is the adapter that matters for the same reason a monitor's does. + capturedMonitor = MonitorFromWindow(window, MONITOR_DEFAULTTONEAREST); if (!session.initialize(window, config.fps, config.captureCursor)) { std::cerr << "ERROR: Failed to initialize WGC window session" << std::endl; return 1; @@ -516,6 +609,8 @@ int main(int argc, char* argv[]) { return 1; } + reportCaptureAdapters(session.device(), capturedMonitor); + // WGC owns the captured texture size. Encoding must use that exact size // until a dedicated GPU scaling pass is introduced; CopyResource requires // matching resource dimensions. diff --git a/scripts/diagnostic-tool/README.md b/scripts/diagnostic-tool/README.md index 046468e3..732e5134 100644 --- a/scripts/diagnostic-tool/README.md +++ b/scripts/diagnostic-tool/README.md @@ -28,8 +28,15 @@ Flags: - `-d, --duration ` recording length before sending stop (default 10) - `-o, --output ` output JSON path (default `./openscreen-diagnostic-.json`) - `--window` capture a window instead of the full display (default: display) +- `--system-audio` also capture system (loopback) audio +- `--mic` also capture the default microphone - `-h, --help` show help +The audio flags matter for reproducing a stop hang. Audio and video writes take +the same sink-writer lock, so a run without audio has nothing to contend with +and can pass on a machine where the app hangs every time. If you are reporting a +hang that happens in the app but not here, re-run with `--system-audio`. + Or use the bundled launcher: - Windows: `diagnostic.bat` - macOS / Linux: `./diagnostic.sh` diff --git a/scripts/diagnostic-tool/diagnostic.mjs b/scripts/diagnostic-tool/diagnostic.mjs index f19020ae..52113427 100644 --- a/scripts/diagnostic-tool/diagnostic.mjs +++ b/scripts/diagnostic-tool/diagnostic.mjs @@ -43,6 +43,8 @@ function parseArgs(argv) { duration: 10_000, output: null, source: "display", + systemAudio: false, + mic: false, help: false, }; const requireNumber = (raw, flag) => { @@ -68,6 +70,10 @@ function parseArgs(argv) { opts.source = value; } else if (arg === "--window") { opts.source = "window"; + } else if (arg === "--system-audio") { + opts.systemAudio = true; + } else if (arg === "--mic") { + opts.mic = true; } else if (arg === "--help" || arg === "-h") { opts.help = true; } else if (arg.startsWith("--")) { @@ -88,7 +94,14 @@ Flags: -o, --output Output JSON path (default: ./openscreen-diagnostic-.json) --source Capture source type (default: display) --window Shortcut for --source window + --system-audio Also capture system (loopback) audio + --mic Also capture the default microphone -h, --help Show this help + +The audio flags are off by default, which is why a plain run cannot reproduce a +hang that only happens with audio: an audio write and a video write contend for +the same sink-writer lock, and with no audio there is nothing to contend with. +If a recording hangs in the app but not here, re-run with --system-audio. `); } @@ -135,8 +148,8 @@ function buildConfig(opts) { displayW: 1920, displayH: 1080, hasDisplayBounds: true, - captureSystemAudio: false, - captureMic: false, + captureSystemAudio: opts.systemAudio, + captureMic: opts.mic, captureCursor: false, microphoneDeviceId: "default", microphoneDeviceName: "", @@ -163,7 +176,8 @@ function run(opts) { const helper = findHelper(); console.log(`[diag] helper: ${helper.path}`); console.log(`[diag] platform: ${process.platform}-${process.arch}`); - console.log(`[diag] duration: ${opts.duration}ms, source: ${opts.source}`); + const audioSummary = [opts.systemAudio && "system", opts.mic && "mic"].filter(Boolean).join("+") || "none"; + console.log(`[diag] duration: ${opts.duration}ms, source: ${opts.source}, audio: ${audioSummary}`); const config = buildConfig(opts); config.outputs.screenPath = config.outputPath; From 5ee4d009037e4e579e0a2a0473c4134ba3e0f64e Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 10 Aug 2026 19:09:07 +0200 Subject: [PATCH 2/3] style(diagnostic): wrap the two lines biome wanted wrapped --- scripts/diagnostic-tool/diagnostic.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/diagnostic-tool/diagnostic.mjs b/scripts/diagnostic-tool/diagnostic.mjs index 52113427..eb521b17 100644 --- a/scripts/diagnostic-tool/diagnostic.mjs +++ b/scripts/diagnostic-tool/diagnostic.mjs @@ -176,8 +176,11 @@ function run(opts) { const helper = findHelper(); console.log(`[diag] helper: ${helper.path}`); console.log(`[diag] platform: ${process.platform}-${process.arch}`); - const audioSummary = [opts.systemAudio && "system", opts.mic && "mic"].filter(Boolean).join("+") || "none"; - console.log(`[diag] duration: ${opts.duration}ms, source: ${opts.source}, audio: ${audioSummary}`); + const audioSummary = + [opts.systemAudio && "system", opts.mic && "mic"].filter(Boolean).join("+") || "none"; + console.log( + `[diag] duration: ${opts.duration}ms, source: ${opts.source}, audio: ${audioSummary}`, + ); const config = buildConfig(opts); config.outputs.screenPath = config.outputPath; From 4232f3fdf52a23ae292e1f503c22d3c30aaeb7ec Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Mon, 10 Aug 2026 19:14:58 +0200 Subject: [PATCH 3/3] fix(wgc): stop the adapter report dereferencing what DXGI did not give it The enumeration ended on DXGI_ERROR_NOT_FOUND and treated every other failure as success. Neither EnumAdapters1 nor EnumOutputs fills its out-pointer when it fails, so any other error -- DXGI_ERROR_NOT_CURRENTLY_AVAILABLE, which is what EnumOutputs returns to a process in session 0, or an invalid call -- left a null ComPtr to be dereferenced on the next line. That is a crash, inside the one function here whose comment promises that its failures are silent because a diagnostic which can end a recording is worse than no diagnostic. Testing FAILED() instead is both correct and shorter: NOT_FOUND is itself a failure code, so one test covers the normal end of the enumeration and every other way it can stop. The null checks are belt and braces on top. Also names --mic alongside --system-audio in the tool's help and the README. The guidance told a reporter to re-run with system audio whatever their failing recording actually used, which would have sent anyone hitting this with a microphone down a path that reproduces nothing. --- electron/native/wgc-capture/src/main.cpp | 12 ++++++++++-- scripts/diagnostic-tool/README.md | 3 ++- scripts/diagnostic-tool/diagnostic.mjs | 3 ++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 1b180aa9..7591595b 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -239,14 +239,22 @@ void reportCaptureAdapters(ID3D11Device* device, HMONITOR targetMonitor) { std::wstring monitorAdapterName; bool monitorAdapterFound = false; bool sameAdapter = false; + // Both loops end on FAILED(), not on DXGI_ERROR_NOT_FOUND specifically. + // NOT_FOUND is itself a failure code, so one test covers the normal end of + // the enumeration and every other way it can stop -- and the other ways are + // what matter here. EnumOutputs returns DXGI_ERROR_NOT_CURRENTLY_AVAILABLE + // to a process in session 0, and neither call fills its out-pointer when it + // fails. Testing only for NOT_FOUND left a null ComPtr to be dereferenced on + // the next line, which would take down a recording from inside the one + // function in this file that promises never to. for (UINT adapterIndex = 0;; ++adapterIndex) { Microsoft::WRL::ComPtr adapter; - if (factory->EnumAdapters1(adapterIndex, &adapter) == DXGI_ERROR_NOT_FOUND) { + if (FAILED(factory->EnumAdapters1(adapterIndex, &adapter)) || !adapter) { break; } for (UINT outputIndex = 0;; ++outputIndex) { Microsoft::WRL::ComPtr output; - if (adapter->EnumOutputs(outputIndex, &output) == DXGI_ERROR_NOT_FOUND) { + if (FAILED(adapter->EnumOutputs(outputIndex, &output)) || !output) { break; } DXGI_OUTPUT_DESC outputDesc{}; diff --git a/scripts/diagnostic-tool/README.md b/scripts/diagnostic-tool/README.md index 732e5134..a817ca20 100644 --- a/scripts/diagnostic-tool/README.md +++ b/scripts/diagnostic-tool/README.md @@ -35,7 +35,8 @@ Flags: The audio flags matter for reproducing a stop hang. Audio and video writes take the same sink-writer lock, so a run without audio has nothing to contend with and can pass on a machine where the app hangs every time. If you are reporting a -hang that happens in the app but not here, re-run with `--system-audio`. +hang that happens in the app but not here, re-run with whichever sources the +failing recording used: `--system-audio`, `--mic`, or both together. Or use the bundled launcher: - Windows: `diagnostic.bat` diff --git a/scripts/diagnostic-tool/diagnostic.mjs b/scripts/diagnostic-tool/diagnostic.mjs index eb521b17..373c8f4b 100644 --- a/scripts/diagnostic-tool/diagnostic.mjs +++ b/scripts/diagnostic-tool/diagnostic.mjs @@ -101,7 +101,8 @@ Flags: The audio flags are off by default, which is why a plain run cannot reproduce a hang that only happens with audio: an audio write and a video write contend for the same sink-writer lock, and with no audio there is nothing to contend with. -If a recording hangs in the app but not here, re-run with --system-audio. +If a recording hangs in the app but not here, re-run with whichever sources the +failing recording used -- --system-audio, --mic, or both. `); }