Skip to content

fix(wgc): add GPU DXGI path for Windows capture readback - #304

Closed
Seb1900 wants to merge 35 commits into
getopenscreen:mainfrom
Seb1900:agent/wgc-dxgi-readback
Closed

fix(wgc): add GPU DXGI path for Windows capture readback#304
Seb1900 wants to merge 35 commits into
getopenscreen:mainfrom
Seb1900:agent/wgc-dxgi-readback

Conversation

@Seb1900

@Seb1900 Seb1900 commented Aug 8, 2026

Copy link
Copy Markdown

Summary

  • add a native DXGI input path for WGC screen frames
  • convert BGRA to NV12 on a separate D3D11 encoding device
  • allocate Media Foundation DXGI samples through IMFVideoSampleAllocatorEx
  • retain the RGB32 CPU readback path for inline webcam PiP and preferSoftwareEncoder

Root cause

On the Windows 10 / WDDM 2.7 machine from #252, the existing staging-texture readback reaches ID3D11DeviceContext::Unmap and never returns. Since that work runs under the shared frame lock on the same device/context as WGC, the WGC drain and video-writer join also stall.

The DXGI path copies the live WGC frame into an owned texture while the capture callback is active, transfers it to a second D3D11 device through a keyed-mutex bridge, performs GPU BGRA-to-NV12 conversion, and submits allocator-owned DXGI samples to the hardware H.264 sink writer. It does not call Map or Unmap.

The existing CPU path remains active when software encoding is requested or when webcam PiP must be composited into the screen buffer.

Validation

Tested on the machine that reproduces #252:

  • repeated 10-second display recordings
  • 10-second window recording
  • 10-second display recording with system audio
  • two consecutive recordings from the installed helper
  • valid non-empty H.264 MP4 output verified by ffprobe
  • video-only stop latency approximately 95-105 ms
  • system-audio stop latency approximately 1 second

The native helper builds successfully with MSVC 2022 and Windows SDK 26100. The software-encoder/readback path is preserved and compiles, but cannot complete on this machine because its original Unmap hang is the hardware-specific failure being addressed.

Closes #252.

github-actions Bot and others added 30 commits August 4, 2026 14:27
electron-builder signed the bundle itself until 26.15.3. Its macPackager
carried a `noIdentity && fallBackToAdhoc` branch handing back
`Identity("-")` when no certificate was found — mandatory on arm64, where
an unsigned binary will not launch. 26.15.3 replaced that path with
`findSigningIdentity`, which returns null instead, so `sign()` leaves on
`return false` and nothing signs the bundle. What ships is the bare
linker signature on the Electron binary: `Identifier=Electron`,
`Sealed Resources=none`.

macOS keys TCC grants to an app's code signature, so such a bundle can
never hold one. v1.9.0-rc.1 asked for Accessibility, the user granted it,
`AXIsProcessTrusted()` still returned false, and the editable-cursor
preflight re-opened the same dialog on every press of record. Recording
was impossible on macOS.

Sign ad-hoc ourselves with the runtime and entitlements electron-builder
would have applied, on both arches — 26.8.1 only fell back on arm64, so
Intel DMGs were never signed at all.

The verification step that should have caught this was gated on signing
being enabled, i.e. it never ran for the only builds that could be
unsigned. Make it unconditional, and assert the signing identifier
against the bundle id: `codesign --verify` passes on the bare linker
signature too, so the identifier is the only thing that separates a
bundle macOS can attach permissions to from one it cannot.
On the macOS and Linux native capture paths the webcam handle was built
with no file name, which selects in-memory buffering. Nothing reached
disk during capture, and finalize had to flatten the whole clip into one
ArrayBuffer to hand it across IPC. Past ~2GB — a take of roughly 20
minutes at BITRATE_BASE — that allocation throws.

The throw was swallowed twice over: fixWebmDuration catches its own
FileReader failure and returns the unpatched blob, then the finalize
catch logged to console and returned undefined, which made the attach
guard skip attachNative*WebcamRecording entirely. The session was
written screen-only and the editor opened as if nothing had happened,
with the camera simply absent. A 23-minute take lost its webcam this
way; shorter takes in the same app session saved fine.

Pass the webcam file name on both native paths so chunks stream to disk
as they arrive, the way the legacy path and the Windows helper already
do. Finalize now branches on isStreaming(): a streamed clip hands over
its name alone and the main process closes the stream and patches the
WebM duration on disk, so nothing multi-gigabyte is ever flattened or
sent across IPC. Buffered short takes keep the existing behaviour.

Every failure now comes back with a reason and reaches the user as a
toast. Silently discarding a completed take is the worst available
outcome, and it was the one that shipped.

Because the bytes now land on disk during capture, a webcam stream that
isn't folded into a saved session is closed and its partial file removed
— otherwise a discarded or failed take orphans a half-written .webm.

The macOS and Linux finalizers were line-for-line copies, so the shared
logic moves into finalizeWebcamAsset() rather than being duplicated
again. Its tests pin both halves of the fix: that a streamed clip is
never read into memory, and that a failure is never silent.

Fixes getopenscreen#253
reindexRecordingOnDisk is Linux-gated by intent — Windows and macOS
record through native helpers that write indexed files at the source, so
the wrapper returns `unsupported-platform` before touching anything
else. The suite never accounted for that: six of its cases inject a fake
remux service and assert the remuxed result, so on a macOS or Windows
checkout they stop at the guard and fail. They were red on a clean tree,
for environmental reasons, with nothing to distinguish them from a real
regression.

Pin process.platform to linux in beforeEach and restore the real value
in afterEach, so the cases exercise the wrapper's own logic on every
platform. The technique is the one the suite's last case already used
inline; hoisting it removes the per-test save/restore boilerplate and
makes the restore hold even when a case throws part-way.

The guard itself stays covered, now over both platforms it exists for
rather than win32 alone, so pinning to Linux can't quietly become the
only thing the suite exercises.

No production code changes. Verified the cases still have teeth:
removing the empty-output size check from reindexRecordingOnDisk fails
the truncated-file case, which had been passing vacuously on macOS.

`npx vitest --run` is now green on macOS: 137 files, 1626 passing.
The four notarization steps carried `&& !contains(github.ref_name, '-')`,
which skipped them for every pre-release. Two costs, and the second is
the one that mattered.

Testers paid the first. A DMG signed with Developer ID but not notarized
is still refused by Gatekeeper — `spctl` answers `rejected, source=
Unnotarized Developer ID` — so anyone testing an RC had to know about
`xattr -rd com.apple.quarantine` before they could open the build they
were being asked to try.

The release paid the second. Notarization never ran until the stable tag,
so the first exercise of the credentials, the certificate chain and
Apple's acceptance of every nested Mach-O landed on the highest-stakes
build there is. The run that first enabled signing died in `Package .app
bundle` on a malformed `MAC_CSC_NAME`; it was caught only because a full
build was dispatched deliberately. Notarizing each RC makes every
candidate a rehearsal.

The trade is a few minutes per macOS job and a dependency on Apple's
notary service being reachable, with `--wait` capped at 15 minutes. If
that turns flaky enough to block RCs, the answer is `continue-on-error`
on pre-releases rather than skipping them again.

Five documentation sites asserted the old behaviour and are corrected
here, so nothing claims RCs are unnotarized after this lands.
The appx manifest advertised en-US and fr-FR only, so the Microsoft Store
product page listed two supported languages for an app that ships thirteen
(SUPPORTED_LOCALES in src/i18n/config.ts), and the listing never surfaced in
Store searches run in the other eleven.

Bare tags for the region-less locales so every region of that language
matches, which is what the renderer's own locale resolution does.
`CSC_NAME` must name the identity without its certificate type;
electron-builder chooses the type itself and refuses a qualified name:

  ⨯ Please remove prefix "Developer ID Application:" from the specified
    name — appropriate certificate will be chosen automatically

It refuses at `Package .app bundle`, which runs after the ffmpeg build
and the compositor addon — about twelve minutes into the macOS job, and
nowhere else. That is what happened the first time signing was enabled
here: twelve minutes to learn that a secret had four extra words.

The mistake is easy to make because the same secret also feeds
`codesign --sign` at `Sign DMG`, and codesign accepts the full common
name, so the qualified form looks correct right up until
electron-builder sees it. The short form satisfies both, since codesign
matches on a substring of the common name.

Check it in `Resolve macOS signing`, where every other signing input is
already validated, and fail in seconds with the value to use instead.
Only prefixes ending in a colon match, so a company whose name starts
with one of these words is not caught.
The helper's stop wait was gated on the frame mutex:

    std::unique_lock lock(mutex);
    control.cv.wait(lock, [&]{ return control.stopRequested.load(); });

`stopRequested` is an atomic with no relationship to what that mutex
protects, but `condition_variable::wait` has to re-acquire it before it
can return -- and that mutex is held across uninterruptible D3D11 work:
the WGC frame callback's CopyResource, and the video writer's
Map(D3D11_MAP_READ) readback. One stalled driver call and the main thread
never came back, before the first [stop-timing] line was ever printed.
That is why issue getopenscreen#252 arrived with an empty diagnostic log.

Give stop its own mutex/CV pair that no frame thread ever touches, route
all nine stop sites through requestStop(), and bound the wait. Then bound
the shutdown itself: every step after the wait calls into a driver or
joins a thread that does, so decoupling the wait alone would only have
moved the hang. Each step gets a deadline under a global ceiling, and a
watchdog force-exits the process naming the step it died in.

Report success as soon as the MP4 index is written rather than at the end
of the process's life, so a wedged GPU teardown no longer costs a
recording that is already complete on disk. Check what finalize()
returns while doing it.

On the app side: keep a listener on the helper for the whole recording so
its diagnostics reach the bug report instead of being dropped between
start and stop, short-circuit a helper that already exited rather than
burning the timeout on a 'close' that can never arrive, and let discard
escape a wedged helper immediately.

The follow-on "Native Windows capture is not running." was separate: the
main process releases its helper handle unconditionally, the renderer did
not, so the next Record click sent a second stop. The same gap existed on
macOS and Linux.

The underlying stall is untouched -- the readback still runs inside the
frame lock, and the D3D adapter is still whichever one Windows hands us
on a four-adapter machine. What changes is that neither can hang the app.

Refs getopenscreen#252
…ture

Review follow-up. The single `Recording stopped. Output path:` line was
gated on `screenFinalized && webcamFinalized`, and the app treats that
line as the only proof a recording is worth keeping. So an optional
second file could veto a complete one: `webcamEncoder.finalize()`
returning false discarded a perfectly indexed screen MP4, which was a
regression against the previous behaviour where its result was ignored
entirely.

The same gate had a second way to fire. Both finalize steps clamp their
deadline to the shared global ceiling, so a screen finalize that spent
most of it left the webcam step already past its deadline, and the
shutdown watchdog killed the process before the announcement ran.

Announce after the screen finalize and before the webcam's, gated on the
screen file alone. A failed webcam finalize is now an ERROR on stderr and
a non-zero exit, which is what it always should have been -- not a lost
recording.

Also from the review:

- Log the `quiesceCapture()` drain outcome at `wgc-quiesce`. It decides
  whether `wgc-session-close` releases the device or skips it, so a
  report that omits it cannot be read.
- Keep `phase=` when parsing `[stop-timing]`. The diagnostic tool
  discarded it, which is the one field naming the step that hung, and
  the summary listed every step twice because begin and end lines both
  matched. Same double-count in the helper harness.
- Pin `OPENSCREEN_WGC_STOP_BUDGET_MS` into the harness's child env and
  derive its hang limit from it. The limit was 30s against a 50s ceiling,
  so a long software-encoder finalize -- the case issue getopenscreen#34 exists for --
  would have been killed and reported as the getopenscreen#252 hang.
- Correct the shutdown budgets in the architecture doc: 8s per step,
  50s overall, not 10s.
- Clear `pendingCursorRecordingData` on the failed-stop path, matching
  the discard path.
The job filtered on `main`, so retargeting this PR at `release/v1.9.0`
removed the artifact its own testing steps tell a reviewer to download.
A recording fix aimed at a release is precisely when someone needs the
compiled helper without a local MSVC toolchain.
…s clips

The layout preset is global — one panel for the whole timeline — but the
camera is per clip: a project mixes a screen+webcam recording with a plain
import without one. `LiveParams::has_webcam` already carried that
distinction, but only `live.rs` derived it, so the preview was right and
every export was wrong.

An export sets its `LiveParams` once for the whole timeline
(`compositor-view-napi`), keeping the `true` default. And `ExportDialog`
sends the SCREEN path as `webcamPath` when a clip has no camera, purely so
the decoder has something valid to open — so the PiP box was drawn with the
screen recording behind it, duplicated into its own corner. That is the
mirror reported in getopenscreen#248, which the greyed-out Layout panel (correctly gated
on `hasAnyClipWithCamera`) then left no way to turn off.

`webcam_is_real` moves next to the field it decides, and
`walk_composited_timeline` — shared by MP4 and GIF on all three backends —
rebinds it per clip. A targeted `set_has_webcam` rather than a per-clip
`set_live_params`, which would clobber the settings the caller posted.

Verified on a real export (`run_composited_multi`, h264_amf) with the camera
path equal to the screen path: the thumbnail is gone. The bench gains a
`--webcam` override because the no-camera case is not different *content*
but an identical *path*, and cannot be replayed otherwise.

Refs getopenscreen#248
The preset is global — one panel for the whole timeline — but the camera is
per clip. `layoutByClip` already carried a resolved layout per visible clip;
it just never asked whether that clip had a camera, so every clip got the
preset whether or not it had anything to put in it.

Gating the camera's draw (`has_webcam`, previous commit) is not enough, and
this is the half that actually shows. The block presets — `dual-frame`,
`vertical-stack` — size the SCREEN off the block: they reserve the camera's
half of the frame. A camera-less clip therefore kept its screen squeezed
into that half with nothing beside it, which no draw-time gate can undo.
Under picture-in-picture the defect is invisible, since the screen stays
full-frame there and only a thumbnail is added.

So a clip with no camera now lays out as if the preset were "no-webcam" —
which `computeCompositeLayout` already implements (full-frame screen, no
webcam rect). The predicate matches the `webcamPath` sent with the clip
exactly, so the layout and the decoder cannot disagree. It is deliberately
NOT `hasAnyClipWithCamera`, which gates the Layout panel and ignores
`visible` on purpose so the panel stays reachable to un-hide a camera.

`PreviewCanvas` had the same hole, and its own comment named it: it hid the
webcam SLOT for a camera-less clip but left the screen geometry alone.

Five regression tests, all of which fail on the parent commit: one per
preset for "only the clip that has a camera gets a webcam rect", and one per
block preset for "the camera-less clip gets its full frame back". The
existing webcamRect test asserted a PiP rect for an asset with no camera —
that premise was the bug, so its fixture gains the camera its preset
presupposes and it goes on testing the px→fraction conversion it describes.

Refs getopenscreen#248
`NotesWindow.module.css` was imported for its side effect only, with no
binding. Rollup tree-shakes a binding-less CSS-module import out of the
production bundle, so the shipped app carried zero `.tiptap` rules — dev
looked fine because Vite injects module CSS at runtime there.

Without `height: 100%` + `overflow-y: auto` the note body stops being a
scroll container and grows to the height of its content. Paste a long
note and ProseMirror scrolls the caret into view, which now scrolls the
`overflow: hidden` shell instead: the toolbar leaves the viewport and
the wheel cannot bring it back. Measured on a 400x540 window with ~5.8k
characters pasted — editor 6360px tall, `scrollTop` pinned, toolbar at
y=-5913, zero buttons hit-testable.

Every selector in the file was already `:global`, so the CSS module
bought nothing: rename it to plain `.css` and drop the wrappers. A plain
CSS import is always emitted. This also restores the mirror transform,
list markers, code blocks and heading sizes in production.
The wallpaper and a 50% padding were already on out of the box, but
roundness, shadow and motion blur all defaulted to 0. The result was a
hard-edged rectangle pasted onto a decorative background: the padding
looked like wasted space rather than a deliberate margin, which is what
issue getopenscreen#271 reported before blaming the padding for it.

Ship the rest of the look instead of removing the half that was there:
roundness 40px, shadow 20%, motion blur 20% (values picked on Windows).
Padding stays at 50.

Projects where the user already moved one of these sliders keep their
value -- `legacyEditor` only stores keys that were explicitly patched --
so only projects that never touched them pick up the new look.

Closes getopenscreen#271
cursor-sampler.exe shipped with no dpiAware manifest and never called
SetProcessDpiAwareness*, so the process was DPI-unaware and Win32 handed it
*virtualized* coordinates: GetCursorInfo().ptScreenPos and GetWindowRect both
come back divided by the primary display's scale factor.

b31bb71 assumed the opposite ("reports raw x/y in physical screen pixels") and
converted the Electron display bounds to physical before normalizing. Both sides
then lived in different spaces, so on a scaled display normalizeSample produced
cx = (x/s)/W instead of x/W: the preview cursor sits at 1/s of its real offset
from the top-left, an error that grows with the distance to the display origin
(at 150% on a 2560px-wide screen, ~850px short at the right edge).

Opt the helper into per-monitor-v2 awareness rather than walking the
normalization back to DIPs: the numbers really do become physical, which is what
every consumer already assumes, and unlike DIP normalization it also holds on
mixed-DPI multi-monitor setups (virtualization always uses the primary display's
scale, whatever monitor the cursor is actually on).

Window captures were never affected: there the sampler supplies its own
GetWindowRect bounds, so numerator and denominator were virtualized together and
the ratio came out right either way.

payload.x/y being physical now, the asset's display lookup needs
screenToDipPoint -- screen.getDisplayNearestPoint works in DIPs and would
otherwise pick the wrong monitor.

macOS and Linux are unaffected: the SCK helper reports its capture frame in
points, the same space as screen.getCursorScreenPoint(), and the PipeWire helper
normalizes against the stream's own pixel dimensions.

Verified: GetProcessDpiAwareness on the rebuilt binary reports PER_MONITOR_AWARE
(was UNAWARE), and the reported position is unchanged at 100% scaling.

Fixes getopenscreen#272
The HUD asked to be input-transparent twice: once here, at construction,
and once from the renderer's mount effect. The first one is the whole bug
in getopenscreen#266 — the app running, the bar painted, and every click, drag and
button dead, forever, from the very first launch.

On Windows `setIgnoreMouseEvents(true, { forward: true })` is a global
WH_MOUSE_LL hook, and that hook is the only route back: Chromium delivers
no pointermove to a window it has made input-transparent, so the renderer
cannot ask to leave the state it is stuck in. Electron latches the install
behind `forwarding_mouse_messages_` and only retries after a
setIgnoreMouseEvents(false) — the call the dead hook prevents. So one hook
that is refused, or that Windows revokes for overrunning the 300 ms
LowLevelHooksTimeout, bricks the UI with no way out.

Construction time is the worst possible moment to ask for it: that hook
callback runs on the main thread, which is still booting the app. The
renderer asks a frame or two later, over IPC, on a thread that is provably
pumping messages — and if that ask never comes, the bar stays clickable
instead of turning into a ghost.

Costs an invisible rectangle that can swallow one desktop click in the two
frames between show and mount. Refs getopenscreen#266.
Two halves, because both are load-bearing after getopenscreen#266: nothing may call
setIgnoreMouseEvents while the HUD window is being constructed, and the
renderer must still ask for it once it has mounted.

The window is recreated through the app's own path (second-instance →
showMainWindow → createHudOverlayWindow) with the native call taped, and
the tape is read back synchronously — no await between arming it and
snapshotting, so no renderer IPC can slip into what is meant to be
construction only. The source selector is opened first purely to keep the
window list non-empty while the HUD is destroyed: emptying it fires
window-all-closed, which quits the app under the test.

Ablated: restoring the deleted line turns duringConstruction into
[[true, {forward: true}]] and the first assertion fails, so the test does
cover the regression it claims to.
"A frame or two" was an assumption. Timing the real app from ready-to-show
to the renderer's first hud-overlay-ignore-mouse-events puts it at 83 and
90 ms over two clean runs — roughly forty times what the comment claimed,
and the number a reviewer should be weighing against getopenscreen#266.

(A third run read -403 ms: the tape catches setIgnoreMouseEvents on the
prototype, so an IPC still in flight from the destroyed HUD's renderer
lands on the new window and dates the ask before the window exists. The
probe was throwaway; the two clean runs are the ones quoted.)
`getTranscript` sliced at 800 segments under a comment reading "segments only —
words would blow the context", written believing a segment was a phrase. On the
production path a segment IS one word: whisper's word timings are mapped
one-to-one in src/lib/captioning/transcribe.ts, and the real fixture has 129
words for 129 segments.

So the cap cut at the 800th WORD. At a normal speaking rate that is the fifth
minute, and nothing in the payload said so — the model read a sixth of a
half-hour recording, trimmed the silences it could see, and reported the job
done. Asking it afterwards does not help either: the history sent back carries
message text, not tool results, so the next turn re-reads the same 800 words.

A whole 30-minute transcript is ~285k characters, ~70k tokens — well inside every
model this app talks to. The cap was a guess, not a measurement, so it is gone
rather than raised. If a recording ever does approach a window, the fix is to
know the window; there is no per-model context budget in the app today, and
picking another number here would only move the silence.

The workbench already carried a `longTranscript` fixture documenting this defect
and never wired it to a scenario. Its comment is now true instead of a to-do.
`ai-agent.md` still announced "up to 800 transcript segments" — the number this
branch removed, and the one that quietly cut a half-hour recording at its fifth
minute. Reference that repeats a limit the code dropped is how the limit gets
re-added.
Every RC of a line shipped the same release body. The notes start tag was
derived from the stable version, so v1.9.0-rc.1 and v1.9.0-rc.2 both spanned
v1.8.0..<tag> — rc.2 just repeated rc.1's list plus its own few entries, and
v1.8.0-rc.8 and rc.9 came out byte-identical. Testers had no way to see what a
re-cut actually changed, which is the one question an RC body has to answer.

Resolve the previous RC of the same line instead, walking down from the current
rc number so a skipped or failed RC doesn't break the chain. rc.1 still falls
back to the previous stable, and stable releases are untouched.

Build the RC body from `git log` rather than --generate-notes. GitHub's
generator lists only the PRs it manages to associate and silently drops real
ones: getopenscreen#254 and getopenscreen#261 were merged into release/v1.9.0 yet never appeared in
v1.9.0-rc.2's body, so an RC could omit the very fix it was cut for. The commit
range is the actual diff. Stable releases keep --generate-notes — they are the
public-facing ones and want the PR links and the New Contributors section.

Needs fetch-depth: 0 on the publish job's checkout for the tags and history.
…d trip

The measured cost of an auto-enhance turn is not its context, it is its shape:
19 tool calls in series, six addTrim and nine addZoom one at a time, each a full
round trip to the provider. `deep-agent/service.ts` says as much where it raises
`recursionLimit` — "one step per silence". On a half-hour recording that is
hundreds of round trips for a decision the model made in one breath.

Two batch tools, and nothing else changes:

- The element schema IS the unitary schema (`z.array(addTrimArgs)`), and the
  executor REPLAYS the unitary tool per item, folding the document forward. Clip
  resolution, anchoring, clamping, the wording of every refusal — identical by
  construction rather than by a second implementation staying in step. A batch of
  N is exactly N unitary calls minus N-1 round trips, and a test asserts that
  against a document built the long way.
- Partial application, deliberately. `replaceTimeline` is the repo's other
  array-taking tool and refuses in one block; that is right for rebuilding a
  timeline and ruinous for adding ten independent cuts. Each item stands alone,
  the result leads with requested / appliedCount / refusedCount, and `refused`
  names the index and the unitary reason. `ok:false` is kept for the one case
  where nothing landed — the only one where the document did not move, and the
  only one where chat-service's applied-calls list may legitimately stay empty.
- No cap on the array, for the reason `getTranscript` just taught us: a number
  guessed here would be the next thing to cut a recording in half in silence.

The unitary tools stay. A one-off correction should not need a one-element array,
and `setTrim` — the tool for "move that trim" — was never in scope for a batch.

Two invariants that had to be found rather than assumed:

- `diffMatches` reads an id at the TOP level of the tool result, so a batch would
  have made the only check that catches a tool lying about its own writes return
  vacuously true — silently, on the calls that write the most. It now verifies
  every entry of `applied`. Removing that branch turns the new oracle test red.
- Overlapping zooms in one batch stay overlapping, because two `addZoom` calls
  already do: no `add*` path clamps against neighbours, in the agent or the UI.
  Deconflicting inside the batch would make the result depend on how the model
  chose to group its calls. Pinned by a test so it stays a decision.

`MUTATING_TOOL_NAMES` carries both names, which is what puts them behind the
"project edits disabled" wall — the existing loop over that table ("no write
escapes by being added later") covers them without a new test.
`addTrims`/`addZooms` promise the model that each entry stands or falls by
itself, and `applyBatch` is written for exactly that. The batch schemas were
not: `z.array(addTrimArgs)` rejected the whole call the moment one entry was
malformed, so nine good cuts died with the tenth and `refused[index]` could
never name the culprit. Worse, LangChain parses the tool schema BEFORE calling
us, so on the product path the call never reached `applyBatch` at all — it
threw "Received tool input did not match expected schema".

The element schema is now advertised rather than enforced, as a union with
`unknown`. The model still reads the full shape in the tool's JSON schema
(`anyOf: [addTrim, {}]`), a bad entry reaches the unitary executor, and that
executor refuses it with the wording it always uses. The container is still
validated: a missing, empty or non-array `ranges` is refused as a whole.

Also corrects two stale tool counts left at 20/19 by the previous commit.

Both CodeRabbit findings on getopenscreen#258.
…to the void

`findMediaLinksByFingerprint` is a read that writes: when the file has moved, it
refreshes `lastKnownPath` so the next lookup is cheaper. Not awaiting that write
is right — a lookup should not pay for it — but `void promise` is not
fire-and-forget, it is fire-and-crash. Nothing was watching, so any failure
surfaced as a process-level `unhandledRejection`.

That is what made the CI Test job intermittent: vitest reports an unhandled
rejection from OUTSIDE every test and fails the run, so the job went red under a
green summary — 1628 passing tests and a stack pointing at a temp directory a
finished suite had already removed (run 30935779600). In the packaged app the
same rejection lands in the main process, where the recovery is worse than a
missed refresh ever was.

The refresh stays unawaited and now logs what it could not do, which is the same
answer `readRegistry` already gives a registry it cannot read.

While in there, `withWriteLock` drops its queue entry once the chain drains,
guarded by an identity check so a writer that queued behind us is not stranded.
The map is keyed by an arbitrary directory path and grew for the life of the
process. This is the pattern `DocumentService.writeProject` already uses, so the
two write queues now read the same.

Two cases cover it. The one that fails on the old code makes the directory
read-only, so the write fails deterministically while the read still works, and
asserts that nothing escapes and the lookup still answers. The second reproduces
the CI shape — remove the directory while the refresh is queued — and asserts
only that no rejection escapes, since either side of that race is acceptable.
…e temp dir

"logs a refresh it cannot write" arranged its failure with `chmod(tempDir,
0o555)`. That is not a portable way to make a write fail: on Windows the bit
lands on a directory attribute that does not stop a file being created inside,
so the refresh succeeded, nothing warned, and the case failed on every Windows
dev machine. `skipIf(process.getuid?.() === 0)` could not catch it either —
`getuid` is undefined there, so the guard read as "not root" and ran anyway.

CI only runs the suite on ubuntu-latest, so this stayed green there while being
permanently red locally — which is the worst shape a test can have: it teaches
you to skim past a red suite on the machine the app is actually developed on.

The write is now failed by injection (`vi.spyOn(fs, "writeFile")`), which
asserts the same thing on every platform and at any privilege level, and the
spy is asserted to have been called so the case cannot pass with the refresh
path deleted. Verified by ablation: dropping the `.catch()` in
findMediaLinksByFingerprint still turns it red, with the rejection escaping as
the unhandled one it exists to prevent.
The suite built a full jsdom for every one of its 140 test files. Only 37 of
them ever touch a DOM, so the other 103 paid for one and threw it away: 719s of
cumulative environment setup against 89s of actual test time. Flipping the
default to `node` and letting the 37 opt back in with a `@vitest-environment`
docblock takes the full run from 175s to 81s, back to back on the same machine,
with no change to what any test asserts.

The 37 were derived by running the suite under `--environment=node` and taking
the files that failed, not by guessing from imports — 12 of them are `.ts`
files (zustand stores, hooks, `platformUtils`) that a `.tsx`-only heuristic
would have missed. `electron/media/audioPeaks.test.ts` already carried the same
docblock the other way round to escape the global jsdom; that one is now
redundant, and left alone.

testTimeout goes to 15s in the same change because the two interact: with the
machine loaded, 11 tests fail and 9 of those are purely "Test timed out in
5000ms" — ordinary component tests that pass in 200ms idle. A single jsdom file
needs ~9.5s just to boot React, so a 5s budget was never a signal about the
test. The one deliberately slow test (20 interleaved real disk writes) keeps
its own longer override.

The config comment also records the four things that look like speedups and
are not — `--no-isolate`, `deps.optimizer.web`, `--pool=threads`,
`--maxWorkers` — with what each measured, so the next person does not re-run
that benchmark. `--no-isolate` is the tempting one: ~20% faster, but it shares
one module registry per worker, which breaks `vi.mock`, and 29 test files
depend on it.
Every agent-facing surface in the repo defined "done" as `npm run test` — the
full suite. AGENTS.md, both harness reins and the git-workflow doc all said it,
so an agent ran ~1670 tests after each edit and turned short tasks into long
ones. They now say: targeted run while working (`npx vitest --run <path>`, or
the new `npm run test:changed`), tsc and biome as the inner loop since they are
seconds, and one full run at the end or left to CI.

Also removes a tier of tests that does not exist. `npm run test:browser` and
`vitest.browser.config.ts` were documented across four files and 80 lines of
writing-tests.md, with a worked example and a timeouts section — but there is
no script, no config, no `*.browser.test.ts` file and no CI job for any of it.
An agent reading that runs a command that cannot work. What actually covers
real codecs and GPU is the Rust suites under `crates/` and the manual
checklist, so the table now points there.

The remaining testing docs pick up the two rules this branch had to learn the
hard way: the environment is node unless a file opts into jsdom, and anything
gated on `process.platform` has to pin it, because CI is Linux-only and an
unpinned Linux-only path is green there and red on every other machine.
`clearTimeout` sat on the line after the `await`, so only the success path ever
reached it. When `fetch` rejects — a real network error, and the case the test
file already drives — control jumped straight to `catch` and the 5s abort timer
stayed armed, firing `controller.abort()` long after the function returned.

`callDiscord` in the sibling `discord-bot-api.mjs` already had this right with
a `try`/`finally`; this is the same shape. The new test pins it with fake
timers on both the rejecting and the non-ok path: reverting the `finally` makes
it fail with `expected 1 to be +0`.
Audit of every `void <call>` in src/ and electron/ after 2c4c426 fixed one of
them, since `void` marks a promise as intentionally detached but does nothing
about its rejection. 122 sites; most are fine and are left alone.

The three in electron/ are the ones that can kill the app, because
`installMainProcessErrorGuards` re-throws every unhandled rejection whose code
is not EPIPE/ECONNRESET/ERR_STREAM_DESTROYED. One was the registry write fixed
in 2c4c426; the other two are already correct — `document-service` voids a
promise that is already `.catch()`ed, and `cliMain`'s chain ends in a `.catch`
that exits non-zero. Neither gets ceremony added.

In the renderer a rejection is console noise rather than a crash, so the fixes
here are the sites where a rejection is not hypothetical:

  * `play()` and `requestFullscreen()` reject routinely (autoplay policy, a
    load interrupting a pending play). VirtualPreview and WebcamOverlay already
    caught theirs; Modals and NewEditorShell did not. Play state is driven by
    the element's own play/pause events in both, so a rejection leaves nothing
    to reconcile — same commented swallow as the existing two.
  * Bare `ipcRenderer.invoke` calls reject when the main handler throws:
    revealInFolder, startNewRecording, and the recording-prefs and
    selected-source reads. These log, because a failed IPC means something is
    genuinely broken.
  * The background duration probe in `useTimeline` ends in `saveDocument`,
    which throws on a failed write — the same shape as the registry bug.

Not fixed here, deliberately: 12 sites where a user-initiated timeline mutation
(removeRegion, removeClip, duplicateClip, insertClipAt, applyTimelineOp) can
fail silently, because neither `useTimeline` nor `useSequentialTimelineOps`
catches anything. Twelve scattered `console.warn`s would bury a real user-facing
failure; they all route through two functions, so the fix belongs there with a
toast — which is a UX change, not an audit cleanup. `handleSave` already does
exactly that, so the pattern to copy is next door.
github-actions Bot and others added 5 commits August 5, 2026 12:31
Every turn began by measuring the history against `DEFAULT_BUDGET_TOKENS =
80_000` and, past 70% of it, blocking on a whole extra summarizer call before
the user's request was even sent — a call that also pays the reasoning budget,
so on a thinking model it is not a cheap one.

That 80k was invented. The app cannot ask a provider how big its context
window is, so the number could not be right for anything: it discards context
at 5% fill on a 1M-token Gemini, and would be far too generous elsewhere. It
is the same mistake `getTranscript` made with its 800 segments, and it gets
the same answer — a guessed limit is deleted, not retuned. Until the app can
learn a real window, the only honest trigger is a person deciding they want
one, which is the button that already exists.

The same guess also gated the manual path: `compactSessionNow` went through
the identical heuristic, so on any ordinary conversation pressing Compact did
nothing at all, silently. Pressing it is now the decision. The one refusal
left is "fewer than 4 messages", which is not a guess about anyone's context
window — folding a single exchange into a summary cannot make it shorter —
plus the existing measured guard that refuses a summary no smaller than what
it replaces.

`compactionBlocked` goes with it: it existed only to stop the automatic path
re-buying the same useless summary, and nothing retries on its own any more.
`DEFAULT_BUDGET_TOKENS` survives as the context pill's denominator and nothing
else; both it and its renderer twin now say in writing that they must never
regain a decision.

Verified by ablation: re-adding the automatic trigger turns four of the six
compaction tests red, including the one that pins that a turn never summarizes
anything by itself.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0af2f2d7-51dc-430a-b007-2ecaba4aa24c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

Copy link
Copy Markdown
Collaborator

Rebased your commit onto main and opened #305 with a hardening commit on top, keeping you as co-author.

Main change: every DXGI setup failure now falls back to the CPU path instead of returning false. As written, the hard error sat between the default sink-writer attempt and the software H.264 retry, and since useDxgiInput is on by default that made the software fallback unreachable. A machine without a hardware H.264 encoder would have stopped recording entirely.

Also shortened the bridge AcquireSync from 5s, since it runs on the writer thread while it holds the frame lock, and a timeout now skips the frame instead of failing the recording.

Two things measurement turned up on a working machine, both worth knowing for your prototype:

  • The D3D manager switches the sink writer onto a hardware MFT, and those default to CBR. A static desktop spent the full 18 Mbps budget, 16.9 Mbps against 1.95 on the CPU path, so files were about 8x larger. MF_MT_AVG_BITRATE does not move them; VBR through ICodecAPI does.
  • VideoProcessorBlt with no colourspace set was picking the driver default. Now full-range BGRA in, studio BT.709 out, with matching tags on both media types. Both paths measure the same afterwards.

We have no hardware that reproduces this, so #305 only proves the GPU path is correct and the fallbacks work. Could you run it on the machine that fails? encoder-selection now carries videoInput (dxgi-nv12 or cpu-rgb32), and the helper prints one [frame-drops] gpu_bridge_contended=<n> line at stop.

Closing this in favour of #305, which carries your commit. Thanks for the prototype and the trace, both did the hard part.

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.

[Bug]: v1.8.0 native Windows recorder still hangs on stop; next attempt says capture is not running

2 participants