From 17a61e5d0d1b207cfa7331903ea1eea1d94cdbbf Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 6 Nov 2026 17:56:57 +0100 Subject: [PATCH] fix(capture): explain macOS -3821 stops caused by low disk space --- .../recording/nativeMacCaptureStop.test.ts | 27 +++++++++++++ electron/recording/nativeMacCaptureStop.ts | 40 ++++++++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/electron/recording/nativeMacCaptureStop.test.ts b/electron/recording/nativeMacCaptureStop.test.ts index 725856799f..277552847d 100644 --- a/electron/recording/nativeMacCaptureStop.test.ts +++ b/electron/recording/nativeMacCaptureStop.test.ts @@ -144,6 +144,33 @@ describe("readNativeMacStopOutcome", () => { }); }); + /** Issue #1496: macOS stops every stream with -3821 while the disk is nearly full. */ + describe("a stream macOS stopped with -3821", () => { + const systemStopped = line({ + event: "error", + code: "capture-stopped-with-error", + message: + 'Error Domain=com.apple.ScreenCaptureKit.SCStreamErrorDomain Code=-3821 "Stream was stopped by the system" UserInfo={NSLocalizedDescription=Stream was stopped by the system}', + }); + const outcomeWithFree = (freeBytes: number | null) => + readNativeMacStopOutcome(started + systemStopped + stopped, null, TARGET, () => freeBytes); + + it("blames the disk, with its free space, when the volume is nearly full", () => { + expect(outcomeWithFree(8_500_000_000)).toEqual({ + ok: true, + screenVideoPath: TARGET, + warning: + "macOS stopped the recording because the disk is almost full (8.5 GB free). Free up space and record again. The part recorded until then was saved.", + }); + }); + + it("does not blame the disk when it has room or cannot be measured", () => { + const plain = "macOS stopped the recording. The part recorded until then was saved."; + expect(outcomeWithFree(200_000_000_000)).toMatchObject({ warning: plain }); + expect(outcomeWithFree(null)).toMatchObject({ warning: plain }); + }); + }); + it("tells the user when the system audio they asked for could not be recorded", () => { const tapFailed = line({ event: "warning", diff --git a/electron/recording/nativeMacCaptureStop.ts b/electron/recording/nativeMacCaptureStop.ts index 3104cd850f..12aea95880 100644 --- a/electron/recording/nativeMacCaptureStop.ts +++ b/electron/recording/nativeMacCaptureStop.ts @@ -1,4 +1,6 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { statfsSync } from "node:fs"; +import path from "node:path"; /** * Stopping a native macOS (ScreenCaptureKit) recording, as a unit that can be @@ -60,6 +62,40 @@ export const SYSTEM_AUDIO_LOST_WARNING = "System audio could not be recorded for type HelperEvent = Record; +/** + * Below this, macOS's CacheDelete daemon purges every few minutes and `replayd` stops every + * ScreenCaptureKit stream with -3821 (issue #1496; the purge goal was ~15 GB on a 500 GB disk). + */ +export const SYSTEM_STOP_LOW_DISK_BYTES = 15 * 1000 ** 3; + +/** Bytes free on the volume holding `filePath`, or null when it cannot be measured. */ +function freeBytesNextTo(filePath: string): number | null { + try { + const stats = statfsSync(path.dirname(filePath)); + return stats.bavail * stats.bsize; + } catch { + return null; + } +} + +/** Why an interrupted take ended, in words the user can act on. */ +function describeEarlyEnd( + interruption: HelperEvent, + screenVideoPath: string, + readFreeBytes: (filePath: string) => number | null, +) { + const reason = messageOf(interruption).replace(/[.\s]+$/, ""); + if (!/SCStreamErrorDomain Code=-3821\b/.test(reason)) { + return `Recording ended early (${reason}). The part recorded until then was saved.`; + } + const freeBytes = readFreeBytes(screenVideoPath); + if (freeBytes !== null && freeBytes < SYSTEM_STOP_LOW_DISK_BYTES) { + const freeGb = (freeBytes / 1000 ** 3).toFixed(1); + return `macOS stopped the recording because the disk is almost full (${freeGb} GB free). Free up space and record again. The part recorded until then was saved.`; + } + return "macOS stopped the recording. The part recorded until then was saved."; +} + function parseHelperEvent(line: string): HelperEvent | null { const candidates = [line]; // stdout and stderr drain into one buffer chunk by chunk, so a stderr line can @@ -131,6 +167,7 @@ export function readNativeMacStopOutcome( output: string, exit: NativeMacCaptureExit | null, targetPath: string | null, + readFreeBytes: (filePath: string) => number | null = freeBytesNextTo, ): NativeMacCaptureStopResult | null { const events = readNativeMacHelperEvents(output); const isInterruption = (event: HelperEvent) => @@ -163,8 +200,7 @@ export function readNativeMacStopOutcome( } const warnings: string[] = []; if (interruption) { - const reason = messageOf(interruption).replace(/[.\s]+$/, ""); - warnings.push(`Recording ended early (${reason}). The part recorded until then was saved.`); + warnings.push(describeEarlyEnd(interruption, screenVideoPath, readFreeBytes)); } if (systemAudioLost) { warnings.push(SYSTEM_AUDIO_LOST_WARNING);