Skip to content

fix(wgc): pull-based frame delivery to stop CopyResource wedging the stop path - #306

Open
abduznik wants to merge 5 commits into
getopenscreen:mainfrom
abduznik:fix/wgc-pull-based-frame-delivery
Open

fix(wgc): pull-based frame delivery to stop CopyResource wedging the stop path#306
abduznik wants to merge 5 commits into
getopenscreen:mainfrom
abduznik:fix/wgc-pull-based-frame-delivery

Conversation

@abduznik

@abduznik abduznik commented Aug 8, 2026

Copy link
Copy Markdown

Reported by

@LuniteLang-Sys in #292: "timed out waiting for native windows capture to stop. Record could not save."

I hit the identical error and dug in. Root cause and fix below.

Root cause

onFrameArrived (the WGC FrameArrived callback) holds the shared frame-state mutex across CopyResource. On hardware where that call wedges inside the display driver, the lock is gone until the process exits. The video-writer thread then blocks trying to acquire the same lock, so both wgc-quiesce's drain and video-writer-join hang, and the shutdown watchdog TerminateProcess()s the helper before encoder-finalize ever runs. That's the 0-byte MP4.

Confirmed with the standalone diagnostic tool (scripts/diagnostic-tool) on my machine: wgc-quiesce hangs 5s (drained=false), video-writer-join gets abandoned at 13s. This happens under both the default and preferSoftwareEncoder paths — it's not specific to one encoder pipeline.

What #254 and #305 do, and why they don't cover this

What this PR does

Removes the callback thread instead of trying to make its lock safer. WgcSession no longer registers FrameArrived by default. writeVideoFrames pulls each frame itself with session.tryGetNextFrame(), on its own schedule, and does the CopyResource there. This is the same design Chromium's WGC capturer uses (modules/desktop_capture/win/wgc_capture_session.cc), which literally comments "we don't listen for the FrameArrived event, so there's no difference" and pulls via TryGetNextFrame() instead, for this exact reason.

With no separate callback thread, there's no second thread for a wedged CopyResource to take a lock down with it. If the call still wedges, it now only blocks the one thread already responsible for noticing stopRequested and giving up — the failure stays local instead of cascading into video-writer-join.

Net diff is smaller than it looks at a glance because the pull-based design deletes the mutex, the in-flight callback counter, and the bounded-drain logic that existed only to make the push model's shutdown safe. None of that is needed when there's nothing pushing.

Why this PR is long

Two reasons, and I want to be upfront about both:

  1. A genuine rollback lever. OPENSCREEN_WGC_LEGACY_FRAME_CALLBACK=1 restores the previous push-based implementation, which is kept alongside the new one in WgcSession rather than deleted. The pull-based path is only verified on my hardware so far — if it regresses on some driver/GPU combination I don't have, this flag gets someone back to the previously-shipped behavior without waiting on a release. I verified the flag is a real escape hatch, not a decorative one: running the same diagnostic tool with it set reproduces the original hang exactly (video-writer-join abandoned at 8020ms). This roughly doubles the diff versus a flag-less version.
  2. Reordered shutdown. There's no separate "quiesce the WGC producer" step anymore — the writer thread's own loop exit is the producer stopping — so session.stop() moved to run right after stopVideoWriter() instead of before it, and the stale wgc-quiesce step is gone. That touches more of the shutdown sequence in main.cpp than the frame-delivery change alone would.

I'd rather ship the flag and the honest diff size than a smaller PR that leaves people with no way back if I've missed something.

Testing

Machine: Windows 10 22H2, Ryzen 5 4500, RTX 4060 Ti (single GPU, no virtual/remote-desktop display adapters — a different profile than the original #252 reporter's multi-adapter machine, which is useful: this isn't a multi-adapter-only bug).

Built wgc-capture.exe locally (MSVC 14.44, Windows SDK 26100) and drove it directly with scripts/diagnostic-tool/diagnostic.mjs, bypassing Electron:

  • Default (pull-based) path, hardware encoder, 5s/10s/30s durations, repeated runs: stop completes in 83-142ms every time (vs. the unpatched 13,000+ ms hang), and every output MP4 has valid ftyp/moov/mdat atoms and is playable.
  • Default path, preferSoftwareEncoder: true: same result, confirms the fix isn't encoder-path-specific.
  • OPENSCREEN_WGC_LEGACY_FRAME_CALLBACK=1: reproduces the original hang exactly, confirming the flag genuinely restores prior behavior.
  • Installed the built helper into a real OpenScreen install (replacing the shipped wgc-capture.exe) and did a full manual pass through the actual app: started a display recording, ran it for about 2 minutes, hit stop (immediate, no hang), opened the recording in the editor, and it loaded and played correctly — no dropped frames or corruption noticed over that length.

Not tested: webcam-overlay recording, real window capture (vs. display capture — the diagnostic tool can't pass a real HWND), recordings longer than a few minutes, or any hardware other than the one machine above. All of those go through the same writeVideoFrames loop so I'd expect them to work, but I want to say plainly what's actually been exercised versus what's just architecturally covered.

Type of change

  • Bug fix

Desktop impact

  • Windows only (macOS/Linux untouched)

Summary by CodeRabbit

  • Performance

    • Improved screen and window capture smoothness with more efficient frame retrieval and processing.
    • Reduced synchronization overhead for more responsive video recording.
  • Bug Fixes

    • Improved capture startup and shutdown reliability.
    • Ensured capture resources are released cleanly after recording ends.
    • Improved handling of window dimensions for consistent video output.
    • Preserved compatibility with the legacy frame delivery path.

…stop path

WgcSession no longer pushes frames via the WGC FrameArrived event onto
a callback thread of its own. writeVideoFrames now pulls each frame
with session.tryGetNextFrame() on its own thread and does the
CopyResource itself, matching Chromium's WgcCaptureSession
(modules/desktop_capture/win/wgc_capture_session.cc), which comments
"we don't listen for the FrameArrived event" for the same reason.

Root cause: onFrameArrived held the shared frame-state mutex across
CopyResource. On hardware where that call wedges inside the display
driver, the lock is gone until the process exits, and the video-writer
thread blocks trying to acquire the same lock -- so both wgc-quiesce's
drain and video-writer-join hang, and the shutdown watchdog
TerminateProcess()es the helper before encoder-finalize ever runs.
Confirmed with the standalone diagnostic tool: wgc-quiesce hung 5s
(drained=false), video-writer-join was abandoned at 13s, 0-byte MP4 --
under both the default and preferSoftwareEncoder paths, so this is not
specific to one encoder pipeline.

OPENSCREEN_WGC_LEGACY_FRAME_CALLBACK=1 restores the previous
push-based implementation (kept alongside the new one in WgcSession)
as a rollback lever, since the pull-based path has only been verified
on one machine so far. Re-running the same diagnostic tool with the
flag set reproduces the original hang exactly (video-writer-join
abandoned at 8020ms), confirming the flag is a working escape hatch
and not just a comment.

Refs getopenscreen#252, getopenscreen#305.
@abduznik
abduznik requested a review from EtienneLescot as a code owner August 8, 2026 17:43
@coderabbitai

coderabbitai Bot commented Aug 8, 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: d04a6cf7-7f75-4a12-b556-a1e4f44ed7d7

📥 Commits

Reviewing files that changed from the base of the PR and between 7b83113 and f21b0a9.

📒 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

WGC capture now uses pull-based frame retrieval on the video-writer thread by default. An environment-controlled legacy callback path remains available. Startup and shutdown ordering now follow writer-thread ownership.

Changes

WGC capture pipeline

Layer / File(s) Summary
Frame delivery contracts and session handling
electron/native/wgc-capture/src/wgc_session.h, electron/native/wgc-capture/src/wgc_session.cpp
WgcSession adds tryGetNextFrame, retains the latest frame, and registers legacy callbacks only when requested. Callback draining and resource cleanup now apply to the selected delivery path.
Writer-thread frame processing
electron/native/wgc-capture/src/main.cpp
The writer selects pull or legacy delivery. The default path retrieves and copies textures on the writer thread. Readback and sample submission synchronization are updated.
Startup and shutdown ordering
electron/native/wgc-capture/src/main.cpp
Startup launches the writer before first-frame polling. Shutdown joins the writer before encoder finalization and WGC session closure. The separate quiesce step is removed.

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

Sequence Diagram(s)

sequenceDiagram
  participant VideoWriter
  participant WgcSession
  participant WGCFramePool
  VideoWriter->>WgcSession: tryGetNextFrame
  WgcSession->>WGCFramePool: Retrieve frame
  WGCFramePool-->>WgcSession: Return texture and timestamp
  WgcSession-->>VideoWriter: Return retained frame
Loading

Possibly related PRs

Suggested reviewers: etiennelescot

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the WGC pull-based frame delivery change and its purpose of preventing CopyResource stop-path wedging.
Description check ✅ Passed The description clearly documents the bug, root cause, implementation, testing, and Windows scope; only non-critical template sections such as release impact and screenshots are absent.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@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: 4

🤖 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/main.cpp`:
- Around line 894-899: Update the comment above captureVideoSample to qualify
that this thread is the only writer on the pull path. Document that on the
legacy path the WGC callback writes latestFrameTexture under frameMutex, and
that the lock held here protects the readback; preserve the existing legacy
locking.
- Around line 1211-1220: Reorder shutdown so encoder finalization completes
before WGC teardown: update both shutdown paths in
electron/native/wgc-capture/src/main.cpp at lines 1211-1220 and 1094-1103 to
call encoder.finalize()/webcamEncoder.finalize() before session.stop(),
preserving the existing stop-step logging. In
electron/native/wgc-capture/src/wgc_session.cpp lines 457-460, make no direct
change; WgcSession::stop() remains responsible for resetting device/context
pointers after finalization.
- Around line 747-748: Explicitly unlock legacyLock immediately after the scoped
block ending near the legacy frame-processing section and before the submission
section. Ensure both submitVideoSample calls execute without holding frameMutex,
while preserving the existing lock behavior inside the block.

In `@electron/native/wgc-capture/src/wgc_session.cpp`:
- Around line 334-364: Update the handler around frameCallback_ retrieval so
callbacksInFlight_ is incremented for every handler that pulls a frame,
regardless of whether the callback is null. Move InFlightGuard construction
outside the callback conditional so it remains active through frame.Close(),
while preserving callback invocation only when callback is non-null and ensuring
the guard is released after all frame cleanup.
🪄 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: 314476d3-190a-44f1-ae10-17e5fc469030

📥 Commits

Reviewing files that changed from the base of the PR and between 4e7a85b and c3ddbed.

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

Comment thread electron/native/wgc-capture/src/main.cpp
Comment thread electron/native/wgc-capture/src/main.cpp Outdated
Comment thread electron/native/wgc-capture/src/main.cpp Outdated
Comment thread electron/native/wgc-capture/src/wgc_session.cpp
- legacyLock (main.cpp writeVideoFrames) outlived the block it was
  scoped for, so on the legacy callback path frameMutex stayed held
  across submitVideoSample -- reintroducing the getopenscreen#115 hazard for that
  path. Unlock explicitly before submission.
- Qualify the "only writer of latestFrameTexture" comment: true on the
  pull-based path only, not the legacy path, where the WGC callback
  thread also writes it under frameMutex.
- Reorder shutdown so encoder.finalize()/webcamEncoder.finalize() run
  before session.stop(). Not a live bug -- MFEncoder holds its own
  ComPtr<ID3D11Device>/ComPtr<ID3D11DeviceContext>, so COM reference
  counting already kept things alive -- but the old order relied on
  that implicitly, and finalizing first removes the dependency
  structurally instead of documenting around it.
- onFrameArrived only counted a handler as in-flight when
  frameCallback_ was non-null, leaving frame.Close() on the no-callback
  path uncounted and outside quiesceLegacyCallback()'s drain. Count
  unconditionally.

Re-verified after these changes with the standalone diagnostic tool:
default path still stops in ~85ms, legacy-flag path still reproduces
the original hang unchanged (confirms the lock-scope fix didn't affect
the flag's intended rollback behavior).

@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: 1

🤖 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 334-359: Move callback capture and callbacksInFlight_ registration
under callbackMutex_ to the start of the handler, before TryGetNextFrame(), and
construct InFlightGuard before acquiring or creating the frame so cleanup is
covered on exceptions. If the captured frameCallback_ is null, return
immediately without accessing sender or the frame pool; otherwise preserve the
existing frame processing and callback 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: 18e09673-ce26-4382-97fb-8ea6bb519fce

📥 Commits

Reviewing files that changed from the base of the PR and between c3ddbed and 9a0c4e4.

📒 Files selected for processing (2)
  • electron/native/wgc-capture/src/main.cpp
  • 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/main.cpp

Comment thread electron/native/wgc-capture/src/wgc_session.cpp
…he frame pool

CodeRabbit's second pass caught what the first fix (9a0c4e4) missed:
callbacksInFlight_ was incremented after TryGetNextFrame()/Surface()/
GetInterface() already ran, not before. quiesceLegacyCallback() could
still observe callbacksInFlight_ == 0 and return while a handler was
mid-acquisition, letting stop() close framePool_ concurrently with
this handler's use of it.

Move the callback capture and counter increment to before
TryGetNextFrame() is called at all, so the entire window this handler
spends touching the pool is covered by the drain. Also closes a
frame.Close() gap on the GetInterface-failure path noticed while
reordering.

Re-verified: default path still stops in ~83ms, legacy-flag path still
reproduces the original hang unchanged.

@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.

♻️ Duplicate comments (1)
electron/native/wgc-capture/src/wgc_session.cpp (1)

342-356: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Return before frame-pool access when frameCallback_ is null.

At Line 352, a handler can block on callbackMutex_ while quiesceLegacyCallback() clears the callback and observes callbacksInFlight_ == 0. The handler can then increment the counter and call sender.TryGetNextFrame() at Line 356 after quiesce returns. stop() can close framePool_ during that access.

If frameCallback_ is null, return while holding callbackMutex_. Increment callbacksInFlight_ only for a handler that captured a non-null callback.

Proposed fix
     {
         std::scoped_lock lock(callbackMutex_);
         callback = frameCallback_;
-        // Counted under the same lock quiesceLegacyCallback() clears the
-        // callback under, so once it has cleared it no new handler can start
-        // and the counter it then drains cannot go back up. Counted
-        // unconditionally (not only when callback is non-null): a handler
-        // that observes a cleared callback still touches the frame pool
-        // below and needs to be covered by the drain too.
+        if (!callback) {
+            return;
+        }
         callbacksInFlight_ += 1;
     }
🤖 Prompt for 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.

In `@electron/native/wgc-capture/src/wgc_session.cpp` around lines 342 - 356,
Update the callback acquisition block in the frame handler to return immediately
while holding callbackMutex_ when frameCallback_ is null, before any frame-pool
access. Only increment callbacksInFlight_ and create InFlightGuard after
capturing a non-null callback, preserving the existing guarded path for active
callbacks.
🤖 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.

Duplicate comments:
In `@electron/native/wgc-capture/src/wgc_session.cpp`:
- Around line 342-356: Update the callback acquisition block in the frame
handler to return immediately while holding callbackMutex_ when frameCallback_
is null, before any frame-pool access. Only increment callbacksInFlight_ and
create InFlightGuard after capturing a non-null callback, preserving the
existing guarded path for active callbacks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4823697f-7d48-4fb5-8b26-f2f766db106c

📥 Commits

Reviewing files that changed from the base of the PR and between 9a0c4e4 and 7b83113.

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

abduznik and others added 2 commits August 8, 2026 21:07
…ack is null

CodeRabbit's third pass on onFrameArrived: a null-callback handler had
nothing useful to do with a frame, but still called TryGetNextFrame()
and incremented callbacksInFlight_. Return immediately, before either,
once frameCallback_ is observed null under callbackMutex_ -- there is
no reason for that handler to touch the pool at all.

The `if (callback)` guard before invoking it is now dead code (the
only path reaching that point already has a non-null callback) and is
removed.

Re-verified: default path still stops in ~84ms, legacy-flag path still
reproduces the original hang unchanged.

@EtienneLescot EtienneLescot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for this — the diagnosis is the useful part, and it is well argued. Pulling on the consumer's own thread is the right shape, and citing Chromium's capturer for the same reason is the right precedent. The write-up is unusually honest about what was and was not exercised, which made it reviewable.

On whether this replaces #305: I do not think it does, and I do not think you have to choose. The two touch different stages of the same pipeline:

WGC frame pool --[delivery]--> latestFrameTexture --[encoder input]--> sink writer
                    ^                                      ^
                  #306                                    #305

#305 removes Map(D3D11_MAP_READ) from the encoder input. #306 removes the shared lock by removing the second thread. They collide textually in main.cpp, not functionally.

The evidence says each one leaves the other's wedge standing:

  • On @Seb1900's machine, #305 took display and window capture from a 13 s hang to a 105 ms stop. So the Map wedge was real and #305 killed it.
  • On yours, #305 still hangs in CopyResource. So there is a second wedge #305 does not touch, which is what this PR removes.
  • But this PR alone leaves captureVideoSample's Map(D3D11_MAP_READ) on the video-writer thread. If that wedges — which is exactly what was observed on Seb1900's hardware — stopVideoWriter() joins a thread that never returns and video-writer-join is abandoned again. "The failure stays local" is true, but local here is the one thread whose join is the abandoned step.

So my read is: two distinct wedges, one per PR, both real, neither sufficient alone. That argues for landing both rather than picking, with this one rebased on top of #305 once that merges — the conflict is in the frame loop you rewrote, which you know better than the rebase would.

Four things below. Only the first is a behaviour change I would want fixed before merge; the rest are worth a look but would not block.

On the rollback flag: keeping it is defensible for one release given the pull path has one machine behind it, and I would rather have your honest diff than a smaller one. But it preserves the exact code path that causes #252, so please open a follow-up issue to remove it — your own comment already says "remove it once the pull-based path has enough field time", and that ages better as an issue than as a comment.

One note on the artifact: main has moved since you opened this. Your branch has picked it up, so your CI build now links the helper against the static CRT (/MT, commit 7f68e9a) — a different binary from the one you measured on. Nothing about your diagnosis depends on it, but if you re-run the diagnostic tool, use a fresh build so we are not comparing across that change.

// for a first frame to arrive -- there is no separate WGC callback thread
// left to deliver one on its own.
if (audioMixer) {
audioMixer->beginTimeline();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This shifts the audio timeline origin ahead of the video's, which was not the case before.

The three tracks anchor to three different clocks:

  • screen video: firstFrameTimestampHns, the first WGC SystemRelativeTime the writer sees (line 838-839)
  • audio: audioMixer->beginTimeline(), which clears the queues and zeroes emittedFrames_
  • separate webcam file: control.recordingStartedAt (line 854)

Before this PR all three were established after the first frame had arrived — the old code waited on the condition variable first, then called beginTimeline() and stamped recordingStartedAt. Here both move ahead of startVideoWriter(), so they are stamped before WGC has delivered anything. Audio and the separate webcam file now lead the screen video by the whole time-to-first-frame: thread start, StartCapture(), and the first FrameArrived. The 10 s ceiling below is the worst case; typical is tens of milliseconds, which is already inside the range where audio leading video reads as a lip-sync error.

The reordering itself is necessary — the writer thread is the producer now, so it has to be running before anything can wait for a first frame. It is only these two stamps that need to stay behind.

Moving them back below the wait is not quite enough on its own, though: the writer reads control.recordingStartedAt at line 854 as soon as it has a frame, so main() stamping it afterwards races the writer's first iteration and would give the webcam branch a default-constructed time_point. The clean version is to have the writer establish both at the moment it captures its first frame — where it already sets firstFrameTimestampHns — and let main() only wait. That restores the old invariant exactly (timeline origin is the first video frame) and removes the race instead of narrowing it.

Worth confirming with a recording of something with a sharp transient — a clap, or any hard audio/video cut — rather than by eye on desktop footage.

latestFrameTimestampHns = legacyLatestFrameTimestampHns;
} else {
if (control.paused) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Pause behaves differently now, in a way that shows on resume.

On the legacy path, onFrameArrived still called TryGetNextFrame() and Close()d the frame; it was the callback that returned early on paused, after the frame had already been consumed and returned to the pool. So frames kept flowing and being discarded during a pause.

Here, tryGetNextFrame() is not called at all while paused, so nothing is consumed. On resume, the first TryGetNextFrame() returns whatever WGC last queued — a frame captured during the pause. Its SystemRelativeTime falls inside the paused window, so after - control.pausedDurationHns() it lands behind lastEncodedVideoTimestampHns and gets pushed forward by the monotonic guard at line 845. The timestamp ends up correct; the pixels are one frame stale.

One stale frame at each resume is minor, but it is a visible artifact on a pause-heavy recording and it is new. Calling tryGetNextFrame() and dropping the result while paused would keep the old behaviour for two lines.

// be mid-CopyResource on across the two-call boundary. currentFrame_
// holds the reference that keeps *outTexture valid until this class's
// next call or stop() closes it.
currentFrame_ = frame;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Holding the frame pins one of only two pool buffers, permanently.

The reasoning for holding it is right — the caller needs the texture to stay valid across the return, and Direct3D11CaptureFrame's reference is what guarantees that. But both initialize() overloads create the pool with CreateFreeThreaded(..., 2, ...), and with one frame always checked out, WGC is left rotating through a single buffer for the entire recording. There is no slack: any jitter in the writer's cadence (a slow WriteSample, a scheduling hiccup) lands while WGC has nowhere to put the next frame, and it drops it.

The push model never had this problem — the callback consumed and closed each frame immediately, so both buffers stayed available.

Probably worth a third buffer, which costs one texture and removes the constraint entirely. Either way it is measurable rather than theoretical: a 60 fps display recording, count encoded frames against elapsed wall time, this branch versus main. If the delivered rate holds at 60, ignore me.

(Combined with the pause behaviour noted in main.cpp, the pinned buffer also lasts for the whole duration of a pause, not just a frame interval.)

// the shared D3D context at exactly the moment we can least afford a stall.
beginStopStep("wgc-quiesce", stepBudgetMs);
// The drain outcome decides the shape of the whole rest of the shutdown:
// a callback that never came back makes wgc-session-close skip the device

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Removing the step is right; losing the line from the trace is a real cost.

The step genuinely has nothing left to do on the pull path — the writer's own loop exit is the producer stopping, exactly as your comment says. No argument there, and I checked: nothing in the TypeScript or the diagnostic tooling parses wgc-quiesce, so no consumer breaks.

The cost is diagnostic. Every field report on #252 so far, from two different machines, is read through this pair of lines:

[stop-timing] step=wgc-quiesce elapsed_ms=5002 drained=false
[stop-timing] step=video-writer-join elapsed_ms=13047 phase=abandoned

drained=false is what tells us a producer sat on the frame lock rather than the writer simply being slow. On this branch a hang produces only the second line, and the first piece of evidence disappears — on the one bug where we are still collecting traces from users, and where your diagnosis and #305's differ precisely on which thread is stuck.

Suggestion: keep emitting a line for the step with a value that says the question no longer applies — drained=n/a or producer=inline — so an old trace and a new one can still be laid side by side. Cheap, and it keeps the vocabulary the reporters already use.

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.

2 participants