Skip to content

refactor(wgc): guard the WinRT calls that can kill the helper silently - #339

Open
EtienneLescot wants to merge 1 commit into
mainfrom
claude/wgc-init-winrt-guard
Open

refactor(wgc): guard the WinRT calls that can kill the helper silently#339
EtienneLescot wants to merge 1 commit into
mainfrom
claude/wgc-init-winrt-guard

Conversation

@EtienneLescot

@EtienneLescot EtienneLescot commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Correction, read this first. This PR was opened on a misdiagnosis. I hit a helper that died with exit 0xC0000409 and no stderr, attributed it to an uncaught WinRT throw, and wrote these guards. It was not that: it was a MAX_PATH stack-buffer overrun, and this branch crashes identically — the guards catch nothing, because __fastfail is not a C++ exception. Details in Testing.

So this is hardening with no observed failure behind it, not a fix for a bug seen in the wild. Judge it on that basis. I have left it open because the defect it addresses is real and independently verifiable by reading; if you would rather not carry defensive code without a reproduction, closing it is a reasonable call.

Since then: rebased onto main with #338 in it, and the GraphicsCaptureSession::IsSupported() pre-flight has been dropped — see What changed. Nothing that remains can refuse a recording that works today.

Every projected C++/WinRT call on the capture-setup path reports failure by throwing, and none of them is caught:

  • winrt::get_activation_factory<GraphicsCaptureItem>()
  • factory.as<IGraphicsCaptureItemInterop>()
  • item_.Size()
  • Direct3D11CaptureFramePool::CreateFreeThreaded()
  • framePool_.CreateCaptureSession()
  • framePool_.FrameArrived()
  • session_.StartCapture()
  • and every projection inside onFrameArrived

If any of them ever throws, the exception leaves initialize() (or, worse, a WinRT delegate), reaches std::terminate, and the process dies with no message. main.cpp has an ERROR: Failed to initialize WGC display session line ready for exactly this and cannot reach it — initialize() does not return false on failure, it takes the process with it.

That is a real gap in a helper whose failures are already hard to diagnose from Electron's side. It is just not a gap anyone has been observed to fall into.

Related issue

No issue. Found while working on #338; see the correction above for why it is not evidence for #252 / #292 / #327, contrary to what this PR first claimed.

Type of change

  • Refactor / maintenance

Release impact

  • No release note needed

Desktop impact

  • Windows

What changed

guardWinrt() turns a throw into a logged false, applied one or two calls at a time so its label alone names the call that threw — no breadcrumb to thread through the way mf_encoder.cpp needs one. It is the counterpart of the existing succeeded() (for calls that return an HRESULT rather than throwing) and reuses the catch shape applySessionOptions already had.

Both initialize() overloads become their step list and nothing else:

return createD3DDevice() &&
    createCaptureItem(monitor) &&
    createFramePoolAndSession() &&
    applySessionOptions(captureCursor) &&
    registerFrameArrived();

which also drops the frame-pool block that was duplicated verbatim between them.

No GraphicsCaptureSession::IsSupported() pre-flight, deliberately — an earlier revision of this PR had one, and it has been removed. It was the only thing here that could refuse a recording that works today: a machine where IsSupported() answers false but capture would in fact have succeeded records fine now and would stop doing so, and there is no evidence either way about whether such a machine exists. That is precisely the shape of #336 — a new gate in front of a path that was working — and a nicer error message does not buy that risk.

Everything that remains only adds a branch that did not exist before, so at worst it never runs. That asymmetry is the whole argument for taking this without a reproduction.

onFrameArrived gets the same treatment, on the hot path — this is the part I would keep even if the rest went. TryGetNextFrame, Surface(), the interop cast and SystemRelativeTime() all throw, and a throw leaving a WinRT delegate is std::terminate: mid-recording, the process simply vanishes. A bad frame is now dropped instead, logged once rather than at frame rate (60 fps of identical warnings is not a diagnostic). The existing InFlightGuard already unwound correctly on exception, so quiesceCapture()'s drain is unaffected.

Not in scope: quiesceCapture() and stop() were already guarded and are untouched.

Testing

Compiles (Windows x64, /W4) run 31484293718, rebased onto main with #338 in it
No behaviour change on a working machine ✅ records cleanly: 132 frames in 4.4 s, full [stop-timing] sequence, exit 0
Catches a real throw not demonstrated — I have no way to make one of these calls throw

The repro that motivated this, and why it was not this

On Windows 11 Home 10.0.26200, the helper printed {"event":"ready"} and died ~230 ms later with 0xC0000409 and no stderr. I read that as an uncaught WinRT throw — 0xC0000409 is what __fastfail reports for an unhandled C++ exception, and there were seven uncaught candidates right where it died.

It was the length of the helper's own .exe path:

exe path output path result
259 chars short 0xC0000409 @ ~290 ms
short 241 chars ✅ records fine
short short ✅ records fine
259 chars, this branch short 0xC0000409 @ ~319 ms — guards log nothing

The last row is the one that matters. 0xC0000409 is STATUS_STACK_BUFFER_OVERRUN, and here it is the literal meaning — a /GS cookie check failing on a MAX_PATH buffer somewhere in the WinRT/WGC stack — not __fastfail(FATAL_APP_EXIT) from an unhandled exception. No try/catch can intercept it. The unzipped diagnostic bundle simply landed in a deeply nested scratch directory.

The helper is fine on this machine at a normal path, before and after this change.

One thing worth someone's attention: MSIX install paths under C:\Program Files\WindowsApps\<publisher>.<package>_<version>_x64__<hash>\ are long by construction. I have not measured how close the Store build gets to 259 characters, and if it is close, this is a real crash with no diagnostic and no way to catch it. That seems worth checking independently of this PR.

What was verified

$env:OPENSCREEN_WGC_CAPTURE_EXE = "C:\short\path\wgc-capture.exe"; npm run test:wgc-helper:win

Recording still works end to end with this branch: readycursor-capturecapture-adapterencoder-selectionrecording-started, 139 frames in 4.6 s, full [stop-timing] sequence, exit 0. The guards only add a path that did not exist, and on a healthy machine nothing takes it.

What is not verified is the guards actually firing, because I cannot make get_activation_factory or CreateFreeThreaded throw on demand. That is the honest state of this PR.

Summary by CodeRabbit

  • Bug Fixes
    • Improved stability when starting and running screen captures.
    • Capture setup failures are now handled gracefully instead of terminating the application.
    • Frame delivery errors no longer crash the capture process.
    • Added diagnostic logging to help identify capture initialization and frame-processing issues.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 149daa06-f3b7-4a1b-be8a-cc99c985f880

📥 Commits

Reviewing files that changed from the base of the PR and between a3d6322 and 8e7b63f.

📒 Files selected for processing (1)
  • electron/native/wgc-capture/src/wgc_session.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • electron/native/wgc-capture/src/wgc_session.cpp

📝 Walkthrough

Walkthrough

WgcSession now guards Windows Graphics Capture setup, startup, and frame delivery. It logs contextual diagnostics, returns failure results for setup errors, preserves even capture dimensions, and limits repeated frame-error warnings.

Changes

WGC exception safety

Layer / File(s) Summary
Guarded capture setup
electron/native/wgc-capture/src/wgc_session.cpp, electron/native/wgc-capture/src/wgc_session.h
Capture-item creation, frame-pool and session setup, and event registration now handle WinRT, standard, and unknown exceptions. Capture dimensions remain rounded to even values.
Safe startup and frame delivery
electron/native/wgc-capture/src/wgc_session.cpp, electron/native/wgc-capture/src/wgc_session.h
StartCapture failures return false. Frame delivery uses a separate helper, drops failed frames, and logs frame errors once per recording.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.65% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: guarding WinRT calls in the WGC helper.
Description check ✅ Passed The description covers the required summary, issue status, change type, release impact, desktop impact, and testing details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/wgc-init-winrt-guard

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@EtienneLescot EtienneLescot changed the title fix(wgc): stop a WinRT throw from killing the helper with no message refactor(wgc): guard the WinRT calls that can kill the helper silently Aug 11, 2026
@EtienneLescot
EtienneLescot force-pushed the claude/wgc-init-winrt-guard branch from 57bd082 to a3d6322 Compare August 11, 2026 10:54

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@electron/native/wgc-capture/src/wgc_session.cpp`:
- Around line 35-44: Revise the comment above guardWinrt to remove claims that
the 0xC0000409 termination was caused by an uncaught WinRT exception or that an
HRESULT was available. Keep the comment limited to projected WinRT calls that
throw and are handled by guardWinrt, without attributing the MAX_PATH
stack-buffer overrun or __fastfail condition to that mechanism.
- Around line 223-232: Update WgcSession::createFramePoolAndSession so
Direct3D11CaptureFramePool::CreateFreeThreaded and
framePool_.CreateCaptureSession(item_) execute in separate guardWinrt regions,
using a distinct CreateCaptureSession label for the latter while preserving the
existing success return behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5c59d0ed-c205-44a5-9e56-e1344d4df75c

📥 Commits

Reviewing files that changed from the base of the PR and between 57bd082 and a3d6322.

📒 Files selected for processing (1)
  • electron/native/wgc-capture/src/wgc_session.cpp

Comment thread electron/native/wgc-capture/src/wgc_session.cpp Outdated
Comment thread electron/native/wgc-capture/src/wgc_session.cpp
Every projected call on the capture path reports failure by throwing, and not
one of them was caught: get_activation_factory, .as<IGraphicsCaptureItemInterop>,
item_.Size(), CreateFreeThreaded, CreateCaptureSession, FrameArrived,
StartCapture, and everything inside onFrameArrived. So the failure mode was
std::terminate -- exit code 0xC0000409, no stderr, nothing. main.cpp has always
had an "ERROR: Failed to initialize WGC display session" line ready for this and
could never reach it, because initialize() did not return false on failure, it
took the process with it.

guardWinrt() turns a throw into a logged false, applied one or two calls at a
time so its label alone names the call that threw -- no breadcrumb to thread
through the way mf_encoder.cpp needs one. It is the counterpart of the existing
succeeded(), for the calls that throw instead of returning an HRESULT, and it
reuses the catch shape applySessionOptions already had. Both initialize()
overloads become their step list and nothing else, which also drops the
frame-pool block that was duplicated between them verbatim.

onFrameArrived gets the same treatment for the same reason, on the hot path: a
throw leaving a WinRT delegate is std::terminate, so mid-recording the process
would simply vanish. A bad frame is now dropped instead, logged once rather than
at frame rate.

No GraphicsCaptureSession::IsSupported() pre-flight, deliberately, though an
earlier draft of this had one and it read well. It is the only thing here that
could refuse a recording that works today -- a machine where IsSupported()
answers false but capture would have succeeded stops recording -- and there is
no evidence either way about whether such a machine exists. That is the shape of
#336: a new gate in front of a path that was working. A nicer error message does
not buy that risk. Everything that remains only adds a branch that did not
exist, so at worst it never runs.

This is hardening, not a fix for an observed failure. The crash that prompted it
turned out to be a MAX_PATH stack-buffer overrun rather than an uncaught throw:
a build with these guards dies identically and logs nothing, because __fastfail
is not an exception. No shipped install path is anywhere near that limit
(measured: 140 chars for the Store build, threshold ~255), so it is a local
testing hazard only.
@EtienneLescot
EtienneLescot force-pushed the claude/wgc-init-winrt-guard branch from a3d6322 to 8e7b63f Compare August 11, 2026 11:26
@EtienneLescot

Copy link
Copy Markdown
Collaborator Author

Both findings were valid, both fixed in 8e7b63f4 (amended — the branch had no human review yet, and this repo is rebase-merge only).

1. The fast-fail diagnosis in the guardWinrt comment. Correct and the more important of the two. I had corrected that misattribution in the PR description and the commit message, and left it sitting in the code comment — the one place that outlives both. It now says the mechanism (a throw unwinds past initialize() into std::terminate, so it could never return false) without claiming that is what produced the 0xC0000409 I actually saw, and states plainly that no such failure has been observed and that the crash which prompted this was a stack-buffer overrun, not catchable here or anywhere.

2. CreateCaptureSession under the CreateFreeThreaded label. Also correct, and it contradicted the comment three lines above it. Split into two regions. Naming the wrong call is worse than naming none — that is the whole premise of labelling these.

One thing the fix surfaced that neither comment mentioned: the corrected wording claimed every region wraps a single call, which is still false for createCaptureItem (activation factory + interop cast + Size()). Rather than split those three I reworded the comment, since they are one step under one name and knowing which threw would not change what you do next.

Not done: the suggested CodeRabbit CLI install. Nothing was needed beyond reading the two comments against the code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant