Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions electron/recording/nativeMacCaptureStop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 38 additions & 2 deletions electron/recording/nativeMacCaptureStop.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -60,6 +62,40 @@ export const SYSTEM_AUDIO_LOST_WARNING = "System audio could not be recorded for

type HelperEvent = Record<string, unknown>;

/**
* 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
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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);
Expand Down
Loading