macOS: recording silently truncates when SCStream stops mid-capture — didStopWithError only writes to stderr and nothing reads it
Problem
On macOS, when the SCStream stops mid-capture, the recording silently truncates. Nothing in the UI changes: the recording indicator stays on, the webcam keeps recording, the cursor track keeps logging, and the timer keeps counting. You discover minutes later, in the editor, that the screen video ends at a fraction of the take.
I lost two takes to this in one session (6:46 recorded → 55 s of video; 8:23 recorded → 115 s of video) and only found the cause by probing the files.
Type
bug
Evidence
Two sessions, full-display capture of a 5K display, mic on, system audio off. Each recording produces four artefacts; here is what each contains:
| Session | Screen video | Its inline audio | Webcam | Cursor track | Actually recorded |
|---|---|---|---|---|---|
| A | 114.89 s | 114.68 s | 501.84 s | 503.4 s | ~8:23 |
| B | 55.02 s | 56.76 s | 406.16 s | 408.6 s | ~6:48 |
The signature is consistent and, I think, diagnostic:
- The video track and its inline audio track stop at the same instant, while the webcam (renderer-side
MediaRecorder) and the cursor monitor (separate process) run the full duration. So it is not the writer failing on one input — it is the wholeSCStreamgoing away, taking both.screenand microphone sample buffers with it. - The file is perfectly well-formed.
ftyp/mdat/moov, and the indexed packet bytes account for 204,156,468 of themdat's 204,156,490 — there is no unindexed tail to recover.finishWriting()ran normally on stop, because as far asAVAssetWriteris concerned nothing went wrong; it simply stopped being fed. (This is what distinguishes it from #821, where the moov is missing.) - The capture does not degrade into the stop, it stops dead. The last second of the truncated file is a clean 60 fps cadence:No lengthening gaps, no tail-off — healthy frames, then nothing. That is a teardown, not starvation.
... 56.605 56.588 56.788 56.655 56.805 (pts_time, video)
Root cause
electron/native/ScreenCaptureKitRecorder.swift:512 — the whole handler:
func stream(_ stream: SCStream, didStopWithError error: Error) {
fputs("Error: \(error.localizedDescription)\n", stderr)
fflush(stderr)
}It does not clear isRecording, does not finalize, does not attempt a restart, and does not notify Electron. The error goes to stderr.
electron/ipc/register/recording.ts:800 — where stderr goes:
captProc.stderr.on("data", (chunk: Buffer) => {
setNativeCaptureOutputBuffer(nativeCaptureOutputBuffer + chunk.toString());
});That buffer is inspected exactly once, immediately after waitForNativeCaptureStart, to test for MICROPHONE_CAPTURE_UNAVAILABLE (line 810). After that nothing reads it for the rest of the recording. So the helper reports the failure and no one is listening, which is why the UI carries on as if all is well.
Why the stream is being torn down in the first place
ScreenCaptureKitRecorder.swift:25,106:
let targetCaptureFPS = 60
let requestedFPS = max(targetCaptureFPS, config.fps ?? targetCaptureFPS)max() makes 60 fps a floor, not a default — a lower config.fps cannot take effect. Full-display capture then sets streamConfig to display bounds × backing scale with no ceiling, so a 2560×1440 Retina display is captured at 5120×2880 @ 60 fps with queueDepth = 6.
The encoder cannot hold that, and the file shows it: in session B, 42.6 s of the 57 s timeline is drop-gaps (gaps up to 4.5 s), 622 frames total — videoInput.isReadyForMoreMediaData is false most of the time and those frames are silently discarded. Sustained back-pressure like that is what precedes the teardown in both of my sessions.
Two things fall out of that:
- Window capture is not a workaround, it is heavier. The
windowIdpath still configures the stream for the entire display at full scale, switches tokCVPixelFormatType_32BGRA(vs the 4:2:0 biplanar format used for display capture), and then crops every frame on the CPU inappendCroppedVideoFrame. More bytes per frame plus per-frame work on the shared sample handler queue. - With the fps floor hardcoded, a user whose stream is being starved has no in-app lever at all. They have to change their display resolution in System Settings.
Expected
If the stream dies mid-recording, the app should say so and stop, rather than continue presenting a recording that is no longer being captured. Concretely:
didStopWithErrorshould mark the capture as failed and drive finalization, so the partial file is closed deliberately and the error carries a reason.- The helper should emit a machine-readable line (e.g.
STREAM_STOPPED: <error>), matching the existingMICROPHONE_CAPTURE_UNAVAILABLEconvention. - The Electron side should keep watching stderr for the whole session rather than sampling it once at start, and surface a stop + notification. A watchdog on frame arrival (no accepted frame for N seconds while unpaused) would also catch silent stalls that never produce a
didStopWithError. - Independently: give
config.fpsreal authority (config.fps ?? targetCaptureFPSrather thanmax(...)), and consider capping capture dimensions for full-display capture so 5K panels do not default to a 885 Mpixel/s stream.
Even just #1 and #2 would convert a silently lost take into an immediate, obvious failure.
Environment
- Recordly 1.4.0 (
dev.recordly.app), installed from the release DMG - macOS 26.2, Apple silicon
- Native ScreenCaptureKit backend, full-display capture of a 27" 5K display (
UI looks like 2560×1440), mic on, system audio off, webcam on - Disk had ~4 GB free, and the truncated file is 71 MB — not a disk-space failure
Possibly related
- #822 — Mission Control gesture stops a window recording. Likely the same
didStopWithErrorpath, but there the stop is at least visible; here it is not. - #821 — long recording ends with a missing
moov. Different failure mode: there the writer never finalizes, here it finalizes cleanly over a short file. - #854 — editor preview freezes on variable-frame-rate recordings. The 75%-drop-gap timeline described above is exactly the kind of source that produces.
Source: webadderallorg/Recordly