diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index fe2f8505..7591595b 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -195,6 +195,102 @@ 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; + // 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 (FAILED(factory->EnumAdapters1(adapterIndex, &adapter)) || !adapter) { + break; + } + for (UINT outputIndex = 0;; ++outputIndex) { + Microsoft::WRL::ComPtr output; + if (FAILED(adapter->EnumOutputs(outputIndex, &output)) || !output) { + 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 +585,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 +594,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 +605,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 +617,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..a817ca20 100644 --- a/scripts/diagnostic-tool/README.md +++ b/scripts/diagnostic-tool/README.md @@ -28,8 +28,16 @@ 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 whichever sources the +failing recording used: `--system-audio`, `--mic`, or both together. + 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..373c8f4b 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,15 @@ 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 whichever sources the +failing recording used -- --system-audio, --mic, or both. `); } @@ -135,8 +149,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 +177,11 @@ 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;