Skip to content

macOS: recording silently truncates when SCStream stops mid-capture — didStopWithError only writes to stderr and nothing reads it #983

Description

@pxshaaa

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:

  1. 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 whole SCStream going away, taking both .screen and microphone sample buffers with it.
  2. The file is perfectly well-formed. ftyp / mdat / moov, and the indexed packet bytes account for 204,156,468 of the mdat's 204,156,490 — there is no unindexed tail to recover. finishWriting() ran normally on stop, because as far as AVAssetWriter is concerned nothing went wrong; it simply stopped being fed. (This is what distinguishes it from Long recording (70min/5GB) crashes on stop — MP4 missing moov atom AND cursor.json never written, recording unusable #821, where the moov is missing.)
  3. The capture does not degrade into the stop, it stops dead. The last second of the truncated file is a clean 60 fps cadence:
    ... 56.605  56.588  56.788  56.655  56.805   (pts_time, video)
    
    No lengthening gaps, no tail-off — healthy frames, then nothing. That is a teardown, not starvation.

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 windowId path still configures the stream for the entire display at full scale, switches to kCVPixelFormatType_32BGRA (vs the 4:2:0 biplanar format used for display capture), and then crops every frame on the CPU in appendCroppedVideoFrame. 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:

  1. didStopWithError should mark the capture as failed and drive finalization, so the partial file is closed deliberately and the error carries a reason.
  2. The helper should emit a machine-readable line (e.g. STREAM_STOPPED: <error>), matching the existing MICROPHONE_CAPTURE_UNAVAILABLE convention.
  3. 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.
  4. Independently: give config.fps real authority (config.fps ?? targetCaptureFPS rather than max(...)), 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

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions