Skip to content

fix(capture-linux): latch capture-started so an early first frame is not lost - #708

Open
askalf wants to merge 4 commits into
getopenscreen:mainfrom
askalf:fix/linux-capture-started-latch
Open

askalf wants to merge 4 commits into
getopenscreen:mainfrom
askalf:fix/linux-capture-started-latch

Conversation

@askalf

@askalf askalf commented Sep 18, 2026

Copy link
Copy Markdown

Summary

  • LinuxNativeCaptureSession.waitUntilCapturing() installed its resolver only at call time and had no "already arrived" latch, while its sibling waitUntilSourceSelected() has exactly that latch. The capture-started handler resolves through an optional chain (this.startedResolve?.()), so when nothing is waiting yet the answer is silently dropped.
  • The production caller (electron/ipc/handlers.ts:2497-2498) does session.arm(); await session.waitUntilCapturing(); — the await lands on a later microtask, and the helper's source-selected + capture-started arrive in one stdout chunk that NdjsonLineReader dispatches back to back inside a single data callback. There is no point at which the caller can install its handler in between.
  • When that interleaving happens the returned promise never settles. The recording is running and the file is filling, but start-native-linux-recording never returns, and this path deliberately has no timeout ("No timeout, on purpose").
  • Fix is 9 added lines in one file: a capturing field, a latch read in waitUntilCapturing() placed after the liveness check, and setting the latch in the capture-started case. No new dependencies, no behaviour change on any other path.
  • 8 regression tests added to the existing test file — 5 discriminating, 3 controls — bringing it to 19. Verified by executing both arms for every one of them.
$ npx vitest --run electron/native-bridge/capture/linuxNativeCaptureSession.test.ts   # BASE (production file at 520f6e5e, all 19 tests present)
     ✓ knows the granted kind by the time the capture is confirmed running 1ms
     × resolves the capture wait immediately once the first frame already landed 15009ms
     × resolves the capture wait when arming and the first frame share a stdout chunk 15006ms
     ✓ still rejects the capture wait when the helper died before any frame 3ms
     ✓ rejects the capture wait when the helper died after its first frame 2ms
     × resolves the capture wait on an undeferred session whose first frame already landed 15001ms
     × resolves a second capture wait after the first was answered by the event 15005ms
     × resolves the capture wait after a non-fatal error follows the first frame 15003ms
     ✓ (control) leaves the capture wait pending until the first frame lands 6ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯
Error: Test timed out in 15000ms.
      Tests  5 failed | 14 passed (19)
   Duration  75.95s

$ npx vitest --run electron/native-bridge/capture/linuxNativeCaptureSession.test.ts   # HEAD b57cbec2
 ✓ electron/native-bridge/capture/linuxNativeCaptureSession.test.ts (19 tests) 66ms
      Tests  19 passed (19)
   Duration  1.08s

The failure mode on base is a hang, not an assertion: the promise never settles, so vitest kills each test at its 15s timeout. That is the bug reproduced exactly. Five 15s timeouts are why the base arm takes 76s and the fixed arm takes 1s.

Decisions

The fix records the fact that already exists in the event stream (a capturing boolean), in the same shape the sibling waitUntilSourceSelected() latch already uses, and changes no other path. capturing is written in exactly one place and read in exactly one place.

The ordering is deliberate and differs from the sibling: waitUntilSourceSelected() checks its latch before the liveness check (a selection that already happened stays true even if the helper later dies). For capturing the opposite holds — a helper that died after its first frame has stopped recording, so answering "capturing" from a stale latch would report a recording that no longer exists and convert a hang into silent data loss. Putting the liveness check first preserves the existing rejection; this is pinned by a test that fails if the two are swapped.

Alternatives rejected:

  • Add a timeout to waitUntilCapturing(). The absence of a timeout is deliberate and documented (the portal picker has no upper bound); a timeout would abort legitimate slow starts while still not delivering the event that did arrive.
  • Have arm() install the resolver before writing record. Only narrows the window — the non-deferred path awaits waitUntilSourceSelected() first, so capture-started can still land before the later await, and arm() should not own promise state.
  • Resolve eagerly inside handleEvent by constructing the promise in the constructor. Larger change, and would make a never-awaited rejection an unhandled rejection at process level.
  • Fix it in the helper by delaying capture-started. It is the Rust side's correct behaviour to report the first frame immediately; a sleep there would be a race fix by timing.

Not run: the manual computer-use E2E pass required "after any change to native capture, preview or export" — this change is in the Electron main-process session wrapper, not native capture, and there was no display/computer-use environment available to run it.

AI assistance: this bug was found and the fix and tests were drafted with AI tooling in my workflow; the tests and checks above were executed as pasted. I'm responsible for the change and will handle review feedback.

Summary by CodeRabbit

  • Bug Fixes
    • Improved capture startup handling so wait requests resolve immediately when capturing has already begun.
    • Improved reliability for deferred capture sessions and repeated wait requests during startup.
    • Capture waits continue to reject when the capture helper exits, while remaining pending until capture startup is confirmed.
    • Preserved correct handling of combined startup events and non-fatal errors.

…not lost

waitUntilCapturing() installed its resolver only when called, while its
sibling waitUntilSourceSelected() latches the answer it already has. The
start handler arms the helper and awaits capturing afterwards, so the
helper's capture-started can already have been parsed — both events
arrive in one stdout chunk and NdjsonLineReader dispatches them back to
back within a single data callback, leaving no point at which a caller
could install its handler.

When that happens the resolve call finds startedResolve null, the answer
is dropped, and the promise never settles: the recording is running and
the file is filling while start-native-linux-recording waits forever,
with no timeout by design.

Latch it the same way source-selected is latched.
A helper that died after its first frame has stopped recording, so the
latch must not answer for it: order the process check first and pin the
boundary with a test that fails when the latch is read first.
…pture waits

Adversarial verification of the capture-started latch: the undeferred
session, a second wait after the event already answered the first, and a
non-fatal error arriving after the first frame all reach the latch and
had no test. Each fails on base as a 15s hang. The control pins the
latch's initial false so the three above cannot pass for the wrong
reason.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 01eb4aae-0f4f-4799-8089-d7c8cb5cbf8c

📥 Commits

Reviewing files that changed from the base of the PR and between b57cbec and b2a1f77.

📒 Files selected for processing (2)
  • electron/native-bridge/capture/linuxNativeCaptureSession.test.ts
  • electron/native-bridge/capture/linuxNativeCaptureSession.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The Linux capture session now records when capture-started arrives. waitUntilCapturing() resolves immediately when the process is alive and capture has already started. Tests update wait-state descriptions and verify the pending state.

Changes

Capture-start latch

Layer / File(s) Summary
Track capture-start state
electron/native-bridge/capture/linuxNativeCaptureSession.ts
The session adds a capturing latch, sets it when capture-started arrives, and checks it in waitUntilCapturing() after the process liveness guard.
Validate capture wait behavior
electron/native-bridge/capture/linuxNativeCaptureSession.test.ts
Tests shorten explanatory comments and verify that the wait remains pending until capture-started arrives.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix

Merge Risk: ⚪ Minimal · up to b2a1f

The capture-start latch addresses the reported pending wait race, with no remaining concrete merge-blocking risk identified.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: latching the Linux capture-started event to prevent an early first frame from being lost.
Description check ✅ Passed The description is detailed and covers the change, rationale, alternatives, regression tests, test results, and omitted manual testing. The issue reference and template checkboxes remain incomplete, b…
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2…
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 a new PR

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.

The test comments name the invariant each case checks instead of the
history behind it; the pending-wait case observes settlement with a
flag and a stdout flush rather than racing a timer; the latch is set
after the telemetry rebase so that comment keeps describing the rebase;
and the liveness comment says only that an earlier capture-started
event does not bypass the check.
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