From 0266217de57813db637461297b9de393070f9e92 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 15:49:54 +0000 Subject: [PATCH 1/2] fix(desktop): stop the host burning frames and buffers during a long share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triaging a Kubuntu host that locks the whole machine up roughly ten minutes into a screen share, hard enough to need a power cycle. Nothing here is confirmed as the cause yet — the evidence dies with the power-off — but all three are real defects on exactly that path, and two of them are unbounded. Compositor drew at the display's refresh rate, not the capture rate. captureStream(30) samples the canvas 30 times a second and discards whatever was drawn in between, but the loop re-armed requestAnimationFrame every callback: 60Hz, or 144Hz on a high-refresh monitor. backgroundThrottling is deliberately off (the host window is backgrounded for the whole share by design), so nothing ever damped it. The canvas is sized to the screen track's native resolution, so on 4K that is a 33MB clear+draw several GB/s against the same GPU the desktop composites with — 2-5x of it thrown away. Gate the draw to the sample rate; the published and recorded output is unchanged. Only runs with the camera bubble on, which fits a freeze that happens some sessions and not others. Both backpressure waits could never settle. A write stream that errors or closes while backpressured never emits 'drain', and neither does an ffmpeg stdin whose process just died — the pipe closes instead. The renderer awaits these calls inside MediaRecorder's ondataavailable, which keeps firing every 250ms regardless, so a single stalled wait leaves every later chunk retained in both processes: 1MB/s at 8Mbps, 2.5MB/s at 4K, growing until the box is out of memory. Settle on the terminal states too. Also pause the compositor's hidden video elements before dropping srcObject, which otherwise leaves them decoding until GC. scripts/freeze-watchdog.sh records memory, load and per-process RSS with an fsync each interval, so the next freeze leaves a last line on disk instead of taking the evidence down with it. Typecheck, lint and prettier clean; 619 desktop tests across 59 files pass. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SuY5Bj41aCoqroCzVGZBPM --- apps/desktop/src/main/recording/index.ts | 25 +++++++- apps/desktop/src/main/streaming/index.ts | 17 ++++- .../hooks/useScreenCameraCompositor.ts | 30 ++++++++- scripts/freeze-watchdog.sh | 64 +++++++++++++++++++ 4 files changed, 128 insertions(+), 8 deletions(-) create mode 100755 scripts/freeze-watchdog.sh diff --git a/apps/desktop/src/main/recording/index.ts b/apps/desktop/src/main/recording/index.ts index e91c28a4..00849ef9 100644 --- a/apps/desktop/src/main/recording/index.ts +++ b/apps/desktop/src/main/recording/index.ts @@ -99,10 +99,29 @@ export function writeRecordingChunk(chunk: Buffer): Promise { return Promise.resolve(true); } // Buffer full — wait for it to drain to disk before accepting more. + // + // A write stream that errors or closes while backpressured never emits + // 'drain', so waiting on it alone leaves this promise pending forever. The + // renderer awaits this call inside MediaRecorder's ondataavailable, which + // keeps firing every 250ms regardless: each stalled call would retain its + // chunk in both processes, growing unbounded until the machine is out of + // memory. Settle on whichever comes first. return new Promise((resolve) => { - handle.once('drain', () => { - resolve(true); - }); + const settle = (result: boolean) => { + handle.off('drain', onDrain); + handle.off('error', onFailure); + handle.off('close', onFailure); + resolve(result); + }; + const onDrain = () => { + settle(true); + }; + const onFailure = () => { + settle(false); + }; + handle.once('drain', onDrain); + handle.once('error', onFailure); + handle.once('close', onFailure); }); } catch (error) { console.error('[Recording] Failed to write chunk:', error); diff --git a/apps/desktop/src/main/streaming/index.ts b/apps/desktop/src/main/streaming/index.ts index b61aa00d..ead12ad9 100644 --- a/apps/desktop/src/main/streaming/index.ts +++ b/apps/desktop/src/main/streaming/index.ts @@ -648,9 +648,22 @@ export async function writeStreamChunk(chunk: Buffer): Promise { if (!hasRoom) { drains.push( new Promise((resolve) => { - stdin.once('drain', () => { + // An ffmpeg that dies while its stdin is backpressured never + // emits 'drain' — the pipe just closes. Waiting only on 'drain' + // would leave this promise pending forever, and because the + // renderer awaits writeStreamChunk before producing the next + // chunk, every later chunk piles up retained in memory instead. + // 'close'/'error' are the pipe's terminal states: settle on them + // too and let the process exit handler do the real cleanup. + const settle = () => { + stdin.off('drain', settle); + stdin.off('close', settle); + stdin.off('error', settle); resolve(); - }); + }; + stdin.once('drain', settle); + stdin.once('close', settle); + stdin.once('error', settle); }) ); } diff --git a/apps/desktop/src/renderer/hooks/useScreenCameraCompositor.ts b/apps/desktop/src/renderer/hooks/useScreenCameraCompositor.ts index 9c69ced3..810d2e4f 100644 --- a/apps/desktop/src/renderer/hooks/useScreenCameraCompositor.ts +++ b/apps/desktop/src/renderer/hooks/useScreenCameraCompositor.ts @@ -29,6 +29,18 @@ interface UseScreenCameraCompositorOptions { const FALLBACK_WIDTH = 1280; const FALLBACK_HEIGHT = 720; const FRAME_RATE = 30; +/** + * captureStream(FRAME_RATE) samples the canvas at most FRAME_RATE times a + * second, so anything drawn between samples is discarded. requestAnimationFrame + * fires at the display's refresh rate — 60Hz, 144Hz on a gaming monitor — and + * `backgroundThrottling: false` (see main/window.ts) keeps it firing at full + * speed for the entire share, because the host window is backgrounded the whole + * time by design. Drawing every callback therefore burned 2-5x the pixel + * bandwidth for frames nobody ever read: a native-resolution 4K canvas is 33MB + * per clear+draw, which at 144Hz is several GB/s of wasted traffic against the + * same GPU the desktop is compositing with. Gate the work to the sample rate. + */ +const FRAME_INTERVAL_MS = 1000 / FRAME_RATE; /** Draw a video into a destination box using `object-cover`, optionally mirrored. */ function drawCover( @@ -104,7 +116,17 @@ export function useScreenCameraCompositor({ const cameraVideo = createHiddenVideo(cameraStream); let rafId = 0; - const draw = () => { + let lastDrawAt = -Infinity; + const draw = (now: number) => { + // Re-arm first so an early return still keeps the loop alive. + rafId = requestAnimationFrame(draw); + + // Sub-millisecond tolerance: at 60Hz the 16.67ms callbacks would + // otherwise alternate just under the 33.3ms gate and halve the output + // to 20fps. + if (now - lastDrawAt < FRAME_INTERVAL_MS - 1) return; + lastDrawAt = now; + const w = canvas.width; const h = canvas.height; @@ -133,8 +155,6 @@ export function useScreenCameraCompositor({ ctx.lineWidth = Math.max(2, diameter * 0.025); ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)'; ctx.stroke(); - - rafId = requestAnimationFrame(draw); }; rafId = requestAnimationFrame(draw); @@ -147,6 +167,10 @@ export function useScreenCameraCompositor({ stream.getTracks().forEach((track) => { track.stop(); }); + // Pause before dropping the source: clearing srcObject alone leaves the + // element decoding until GC gets to it. + screenVideo.pause(); + cameraVideo.pause(); screenVideo.srcObject = null; cameraVideo.srcObject = null; setOutputStream(null); diff --git a/scripts/freeze-watchdog.sh b/scripts/freeze-watchdog.sh new file mode 100755 index 00000000..f669d8bc --- /dev/null +++ b/scripts/freeze-watchdog.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# +# Records what the machine was doing in the seconds before a hard freeze. +# +# A freeze that forces a power-off takes the evidence with it: journald buffers +# in memory, and the desktop is too wedged to read anything off the screen. This +# writes one line per interval and fsyncs it, so the last line on disk after the +# reboot is the last moment the machine was alive. +# +# ./scripts/freeze-watchdog.sh # log to ~/pairux-freeze.log +# ./scripts/freeze-watchdog.sh /tmp/other.log 1 +# +# After a freeze, reboot and read the tail: +# +# tail -30 ~/pairux-freeze.log +# +# What the last lines tell you: +# * mem_avail_mb falling toward zero, swap_used climbing -> memory exhaustion. +# The kernel thrashes reclaim long before the OOM killer fires, which is what +# a whole-desktop freeze on Ubuntu usually is. `rss_top` names the culprit. +# * everything steady, log just stops -> not memory. Suspect +# a GPU/driver hang; check `journalctl -b -1 -k | grep -iE 'gpu|drm|i915|amdgpu'`. +# * cpu_load pinned at/above core count with memory fine -> saturation, not a leak. + +set -uo pipefail + +LOG="${1:-$HOME/pairux-freeze.log}" +INTERVAL="${2:-2}" +CORES="$(nproc)" + +printf 'watchdog started %s | interval=%ss | cores=%s | log=%s\n' \ + "$(date -Is)" "$INTERVAL" "$CORES" "$LOG" | tee -a "$LOG" + +while true; do + ts="$(date -Is)" + + # MemAvailable is the honest number: "free" ignores reclaimable page cache. + read -r mem_avail_mb mem_total_mb swap_used_mb < <( + awk '/^MemAvailable:/{a=$2} /^MemTotal:/{t=$2} /^SwapTotal:/{st=$2} /^SwapFree:/{sf=$2} + END{printf "%d %d %d", a/1024, t/1024, (st-sf)/1024}' /proc/meminfo + ) + + load="$(awk '{print $1}' /proc/loadavg)" + + # The three biggest resident processes, so a runaway names itself. + rss_top="$(ps -eo rss=,comm= --sort=-rss 2>/dev/null | head -3 | + awk '{printf "%s=%dMB ", $2, $1/1024}')" + + # Everything the Electron app is holding, summed across its helper processes. + pairux_mb="$(ps -eo rss=,args= 2>/dev/null | + grep -i pairux | grep -v grep | + awk '{s+=$1} END{printf "%d", s/1024}')" + pairux_procs="$(pgrep -ic -f pairux 2>/dev/null || echo 0)" + + printf '%s mem_avail_mb=%s/%s swap_used_mb=%s cpu_load=%s/%s pairux_rss_mb=%s pairux_procs=%s rss_top=%s\n' \ + "$ts" "$mem_avail_mb" "$mem_total_mb" "$swap_used_mb" "$load" "$CORES" \ + "${pairux_mb:-0}" "$pairux_procs" "$rss_top" >> "$LOG" + + # Without this the last few seconds — the ones that matter — die in the page + # cache when power is cut. + sync -d "$LOG" 2>/dev/null || sync + + sleep "$INTERVAL" +done From 0bc419ab342be4add71d4f9a638e8c6c40b50d84 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 29 Aug 2026 16:03:29 +0000 Subject: [PATCH 2/2] fix(desktop): bound the host's resource use, and stop before the machine does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the frame/buffer fixes: put an actual ceiling on what a host consumes, and a floor under what it is allowed to do to the machine. The quality setting never bound anything. It was applied in exactly one place, `applyConstraints({ width: { ideal } })`, and `ideal` is a hint the source may ignore — Chromium's desktop capturer does. A 4K monitor kept producing a 3840x2160 track no matter what the user picked, and everything downstream sized itself from that: the compositor canvas allocated 8.3M pixels a frame to composite output that was going to be encoded at 1080p anyway. The bound now lives where it binds — `max` on getDisplayMedia, the mandatory maxima on the desktopCapturer path — and the compositor fits the canvas to the same budget, keeping the source's aspect ratio. Capture also asks for 30fps instead of up to 60: screen content is static, and every extra frame is another full-resolution encode on a machine already compositing, recording and feeding ffmpeg. `fitWithin` floors to even rather than rounding to a multiple of 16. Rounding turned a 1080 bound into 1088 and overshot the limit being applied; a test caught it. Even is what 4:2:0 chroma actually needs, and the presets were never 16-aligned anyway (1080 is not a multiple of 16). The resource guard is the backstop for whatever is still unaccounted for. It watches MemAvailable — not MemFree, which excludes reclaimable cache and would fire constantly — and when the machine is genuinely short it stops the recording, stops any egress and tells the renderer to drop capture. Each level fires once per episode and re-arms only after memory recovers, because the critical handler ends a session and repeating it would loop. Stopping the recording first is deliberate: closing the write stream is what finalises the file. A machine that seizes never runs `recording:stop`, which is why sessions that ended in a freeze never showed up — the WebM was left without its metadata. A guarded stop keeps the recording; a power cut does not. Typecheck, lint and prettier clean; 645 desktop tests across 61 files pass, 26 of them new. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SuY5Bj41aCoqroCzVGZBPM --- .../src/main/health/resourceGuard.test.ts | 163 +++++++++++++++++ apps/desktop/src/main/health/resourceGuard.ts | 171 ++++++++++++++++++ apps/desktop/src/main/index.ts | 64 +++++++ apps/desktop/src/preload/api.ts | 5 + .../components/capture/CapturePreview.tsx | 35 ++++ .../hooks/useScreenCameraCompositor.ts | 23 ++- .../src/renderer/lib/captureQuality.test.ts | 103 +++++++++++ .../src/renderer/lib/captureQuality.ts | 79 ++++++++ apps/desktop/src/renderer/routes/home.tsx | 81 +++++---- 9 files changed, 684 insertions(+), 40 deletions(-) create mode 100644 apps/desktop/src/main/health/resourceGuard.test.ts create mode 100644 apps/desktop/src/main/health/resourceGuard.ts create mode 100644 apps/desktop/src/renderer/lib/captureQuality.test.ts create mode 100644 apps/desktop/src/renderer/lib/captureQuality.ts diff --git a/apps/desktop/src/main/health/resourceGuard.test.ts b/apps/desktop/src/main/health/resourceGuard.test.ts new file mode 100644 index 00000000..7260cc42 --- /dev/null +++ b/apps/desktop/src/main/health/resourceGuard.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + classifyMemory, + readSystemMemory, + startResourceGuard, + DEFAULT_THRESHOLDS, + type MemorySnapshot, +} from './resourceGuard'; + +const MEMINFO = [ + 'MemTotal: 16323216 kB', + 'MemFree: 361284 kB', + 'MemAvailable: 8216044 kB', + 'Buffers: 123456 kB', +].join('\n'); + +describe('readSystemMemory', () => { + it('reads MemAvailable rather than MemFree', () => { + // The whole point: MemFree here is 352MB, which would read as a machine + // about to die, while 8GB is actually reclaimable and available. + const snapshot = readSystemMemory(() => MEMINFO); + + if (process.platform === 'linux') { + expect(snapshot.availableMb).toBe(8023); + expect(snapshot.totalMb).toBe(15941); + } else { + // Non-Linux takes the os.freemem() path and ignores the fixture. + expect(snapshot.totalMb).toBeGreaterThan(0); + } + }); + + it('falls back to portable numbers when /proc is unreadable', () => { + const snapshot = readSystemMemory(() => { + throw new Error('ENOENT'); + }); + + expect(snapshot.totalMb).toBeGreaterThan(0); + expect(snapshot.availableMb).toBeGreaterThanOrEqual(0); + }); + + it('falls back when MemAvailable is missing from an older kernel', () => { + const snapshot = readSystemMemory(() => 'MemTotal: 16323216 kB\nMemFree: 361284 kB'); + expect(snapshot.totalMb).toBeGreaterThan(0); + }); +}); + +describe('classifyMemory', () => { + const at = (availableMb: number): MemorySnapshot => ({ availableMb, totalMb: 16000 }); + + it('is ok with headroom', () => { + expect(classifyMemory(at(8000))).toBe('ok'); + }); + + it('warns at the warning threshold and below', () => { + expect(classifyMemory(at(DEFAULT_THRESHOLDS.warningMb))).toBe('warning'); + expect(classifyMemory(at(1000))).toBe('warning'); + }); + + it('is critical at the critical threshold and below', () => { + expect(classifyMemory(at(DEFAULT_THRESHOLDS.criticalMb))).toBe('critical'); + expect(classifyMemory(at(0))).toBe('critical'); + }); + + it('honours custom thresholds', () => { + expect(classifyMemory(at(900), { warningMb: 2000, criticalMb: 1000 })).toBe('critical'); + }); +}); + +describe('startResourceGuard', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + const guard = ( + availability: number[], + overrides: Partial[0]> = {} + ) => { + const onCritical = vi.fn(); + const onWarning = vi.fn(); + let index = 0; + const stop = startResourceGuard({ + isSharing: () => true, + onCritical, + onWarning, + intervalMs: 1000, + readMemory: () => ({ + availableMb: availability[Math.min(index++, availability.length - 1)], + totalMb: 16000, + }), + ...overrides, + }); + return { onCritical, onWarning, stop }; + }; + + it('stays quiet while there is headroom', () => { + const { onCritical, onWarning, stop } = guard([8000, 8000, 8000]); + vi.advanceTimersByTime(3000); + expect(onCritical).not.toHaveBeenCalled(); + expect(onWarning).not.toHaveBeenCalled(); + stop(); + }); + + it('sheds load once memory is critical', () => { + const { onCritical, stop } = guard([8000, 300]); + vi.advanceTimersByTime(2000); + expect(onCritical).toHaveBeenCalledTimes(1); + expect(onCritical).toHaveBeenCalledWith({ availableMb: 300, totalMb: 16000 }); + stop(); + }); + + it('does not tear the session down again on every poll', () => { + // The critical handler ends a session; firing it repeatedly would loop. + const { onCritical, stop } = guard([300, 300, 300, 300]); + vi.advanceTimersByTime(4000); + expect(onCritical).toHaveBeenCalledTimes(1); + stop(); + }); + + it('re-arms after memory recovers', () => { + const { onCritical, stop } = guard([300, 8000, 300]); + vi.advanceTimersByTime(3000); + expect(onCritical).toHaveBeenCalledTimes(2); + stop(); + }); + + it('does not announce a warning while recovering from critical', () => { + const { onCritical, onWarning, stop } = guard([300, 1000]); + vi.advanceTimersByTime(2000); + expect(onCritical).toHaveBeenCalledTimes(1); + expect(onWarning).not.toHaveBeenCalled(); + stop(); + }); + + it('warns before it is too late to act', () => { + const { onCritical, onWarning, stop } = guard([1000]); + vi.advanceTimersByTime(1000); + expect(onWarning).toHaveBeenCalledTimes(1); + expect(onCritical).not.toHaveBeenCalled(); + stop(); + }); + + it('ignores an idle app — other processes are not its business', () => { + const { onCritical, stop } = guard([100, 100], { isSharing: () => false }); + vi.advanceTimersByTime(2000); + expect(onCritical).not.toHaveBeenCalled(); + stop(); + }); + + it('stops polling once torn down', () => { + const { onCritical, stop } = guard([8000, 300, 300]); + vi.advanceTimersByTime(1000); + stop(); + vi.advanceTimersByTime(5000); + expect(onCritical).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/health/resourceGuard.ts b/apps/desktop/src/main/health/resourceGuard.ts new file mode 100644 index 00000000..bb012330 --- /dev/null +++ b/apps/desktop/src/main/health/resourceGuard.ts @@ -0,0 +1,171 @@ +/** + * Stops the share before the host machine dies. + * + * A screen-share host is one of the few desktop apps that can genuinely take a + * whole machine down: it encodes video, composites a canvas, writes a recording + * and feeds ffmpeg, all at once, for as long as the session lasts. On Linux the + * failure mode is not a tidy crash — with little or no swap the kernel thrashes + * page reclaim long before the OOM killer picks a victim, and the desktop stops + * responding hard enough to need a power cycle. The user loses the machine, and + * because nothing ever ran `recording:stop`, they lose the recording too: the + * WebM is left without the metadata that finalises it. + * + * So watch how much memory the OS still has, and when it gets genuinely scarce, + * shut our own load down first. Stopping a share is a bad outcome; being the + * reason someone has to hold their power button is a worse one — and a clean + * stop finalises the recording, which a freeze does not. + */ + +import * as fs from 'fs'; +import * as os from 'os'; + +export type MemoryPressure = 'ok' | 'warning' | 'critical'; + +export interface MemorySnapshot { + /** MB the OS can hand out without swapping. */ + availableMb: number; + /** MB of RAM installed. */ + totalMb: number; +} + +export interface PressureThresholds { + warningMb: number; + criticalMb: number; +} + +/** + * Absolute headroom, not a percentage: what makes a desktop seize is the number + * of megabytes left, and a percentage would set an absurd bar on a 64GB + * workstation and a uselessly low one on an 8GB laptop. + */ +export const DEFAULT_THRESHOLDS: PressureThresholds = { + warningMb: 1_500, + criticalMb: 600, +}; + +export const POLL_INTERVAL_MS = 5_000; + +/** + * Read how much memory is actually available. + * + * On Linux this must be MemAvailable, not MemFree. MemFree excludes reclaimable + * page cache, so a perfectly healthy machine reports almost none of it and any + * threshold against it would fire constantly. MemAvailable is the kernel's own + * estimate of what a new allocation could get, which is the question being + * asked here. + */ +export function readSystemMemory( + readFile: (path: string) => string = defaultReadFile +): MemorySnapshot { + if (process.platform === 'linux') { + try { + const meminfo = readFile('/proc/meminfo'); + const available = matchKb(meminfo, 'MemAvailable'); + const total = matchKb(meminfo, 'MemTotal'); + if (available !== null && total !== null) { + return { availableMb: Math.round(available / 1024), totalMb: Math.round(total / 1024) }; + } + } catch { + // Fall through to the portable numbers. + } + } + + return { + availableMb: Math.round(os.freemem() / 1024 / 1024), + totalMb: Math.round(os.totalmem() / 1024 / 1024), + }; +} + +function defaultReadFile(path: string): string { + return fs.readFileSync(path, 'utf8'); +} + +function matchKb(meminfo: string, key: string): number | null { + const match = new RegExp(`^${key}:\\s+(\\d+) kB$`, 'm').exec(meminfo); + return match ? Number(match[1]) : null; +} + +export function classifyMemory( + snapshot: MemorySnapshot, + thresholds: PressureThresholds = DEFAULT_THRESHOLDS +): MemoryPressure { + if (snapshot.availableMb <= thresholds.criticalMb) return 'critical'; + if (snapshot.availableMb <= thresholds.warningMb) return 'warning'; + return 'ok'; +} + +export interface ResourceGuardDeps { + /** True while there is something worth shutting down. */ + isSharing: () => boolean; + /** Shed load: stop capture, recording and any egress. */ + onCritical: (snapshot: MemorySnapshot) => void; + /** Tell the user once, while there is still room to act. */ + onWarning?: (snapshot: MemorySnapshot) => void; + readMemory?: () => MemorySnapshot; + thresholds?: PressureThresholds; + intervalMs?: number; +} + +/** + * Begin watching. Returns the stop function. + * + * Each level fires once per episode and re-arms only after memory recovers to + * 'ok'. Without that, a machine sitting just under the line would fire on every + * poll — and the critical handler tears a session down, so repeating it would + * turn one bad moment into a loop. + */ +export function startResourceGuard(deps: ResourceGuardDeps): () => void { + const { + isSharing, + onCritical, + onWarning, + readMemory = () => readSystemMemory(), + thresholds = DEFAULT_THRESHOLDS, + intervalMs = POLL_INTERVAL_MS, + } = deps; + + let reported: MemoryPressure = 'ok'; + + const tick = (): void => { + // Only meaningful while we are the load. Sitting idle at the login screen, + // the machine's memory is somebody else's business. + if (!isSharing()) { + reported = 'ok'; + return; + } + + const snapshot = readMemory(); + const pressure = classifyMemory(snapshot, thresholds); + + if (pressure === 'ok') { + reported = 'ok'; + return; + } + if (pressure === reported) return; + // Dropping back to 'warning' after 'critical' is a recovery, not a new + // thing to announce. + if (pressure === 'warning' && reported === 'critical') return; + + reported = pressure; + + if (pressure === 'critical') { + console.error( + `[ResourceGuard] Only ${String(snapshot.availableMb)}MB of ${String(snapshot.totalMb)}MB available — stopping the share before the machine stalls` + ); + onCritical(snapshot); + } else { + console.warn( + `[ResourceGuard] Memory is low: ${String(snapshot.availableMb)}MB of ${String(snapshot.totalMb)}MB available` + ); + onWarning?.(snapshot); + } + }; + + const timer = setInterval(tick, intervalMs); + // Never hold the process open just to take a measurement. + timer.unref(); + + return () => { + clearInterval(timer); + }; +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 5c8d6dfd..6728a832 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -9,6 +9,9 @@ import { initializeMenu, showAboutDialog } from './platform'; import { clearStoredAuth, clearStoredCredentials } from './auth/secure-storage'; import { setMainWindow as setStreamingMainWindow } from './streaming'; import { startDaemon, stopDaemon } from './daemon'; +import { getRecordingStatus, stopRecording } from './recording'; +import { getAllStreamStatuses, stopAllStreams } from './streaming'; +import { startResourceGuard, type MemorySnapshot } from './health/resourceGuard'; import { findDeepLinkArg, handleDeepLink, @@ -187,6 +190,43 @@ if (!gotTheLock) { let mainWindow: BrowserWindow | null = null; +/** Torn down on quit; see the resource guard below. */ +let stopResourceGuard: (() => void) | null = null; + +/** + * Shed everything this app is doing, in the order that loses the least. + * + * Recording goes first and is awaited: stopping it closes the write stream, + * which is what gives the file the metadata that makes it playable. A machine + * that seizes instead takes the recording with it, so finalising here is the + * difference between the user keeping their session and losing it. + */ +async function shedLoad(snapshot: MemorySnapshot): Promise { + if (getRecordingStatus().isRecording) { + try { + const result = await stopRecording(); + console.error(`[ResourceGuard] Recording finalised at ${result.path ?? 'unknown path'}`); + } catch (error) { + console.error('[ResourceGuard] Failed to finalise the recording:', error); + } + } + + const liveStreams = getAllStreamStatuses().length; + if (liveStreams > 0) { + stopAllStreams(); + console.error(`[ResourceGuard] Stopped ${String(liveStreams)} outbound stream(s)`); + } + + // The renderer owns capture and the WebRTC publication, so it has to stop + // those itself. It also owns the only UI that can explain what happened. + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('resource:critical', { + availableMb: snapshot.availableMb, + totalMb: snapshot.totalMb, + }); + } +} + async function createWindow(): Promise { mainWindow = await createMainWindow(isWayland); setStreamingMainWindow(mainWindow); @@ -230,6 +270,28 @@ void app.whenReady().then(async () => { await createWindow(); + // Watch for the machine running out of memory. A host that keeps encoding + // through that does not fail politely — it takes the desktop with it. + stopResourceGuard = startResourceGuard({ + // A plain screen share counts: it is the encode that costs, and the + // recording and the egress are both optional extras on top of it. + isSharing: () => + getTraySession() !== null || + getRecordingStatus().isRecording || + getAllStreamStatuses().length > 0, + onCritical: (snapshot) => { + void shedLoad(snapshot); + }, + onWarning: (snapshot) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('resource:warning', { + availableMb: snapshot.availableMb, + totalMb: snapshot.totalMb, + }); + } + }, + }); + if (isDaemonMode) { console.log('[Main] Daemon mode: accepting session commands from the web app'); try { @@ -323,6 +385,8 @@ app.on('window-all-closed', () => { app.on('before-quit', () => { console.log('[Main] App quitting...'); + stopResourceGuard?.(); + stopResourceGuard = null; destroyTray(); // Withdraw the tailnet mapping so a stopped daemon leaves nothing published. void stopDaemon(); diff --git a/apps/desktop/src/preload/api.ts b/apps/desktop/src/preload/api.ts index 5e5f2323..df96e06d 100644 --- a/apps/desktop/src/preload/api.ts +++ b/apps/desktop/src/preload/api.ts @@ -528,6 +528,11 @@ export interface IPCEvents { 'recording:stopped': { path: string; duration: number }; 'recording:error': { error: string }; 'recording:space-warning': { availableGb: number }; + + // The machine is running out of memory. 'critical' means main has already + // stopped the recording and any egress, and the renderer must drop capture. + 'resource:warning': { availableMb: number; totalMb: number }; + 'resource:critical': { availableMb: number; totalMb: number }; 'tray:end-session': undefined; 'tray:toggle-pause': undefined; navigate: string; diff --git a/apps/desktop/src/renderer/components/capture/CapturePreview.tsx b/apps/desktop/src/renderer/components/capture/CapturePreview.tsx index 4cfa314d..f5a5f2dd 100644 --- a/apps/desktop/src/renderer/components/capture/CapturePreview.tsx +++ b/apps/desktop/src/renderer/components/capture/CapturePreview.tsx @@ -152,6 +152,8 @@ export function CapturePreview({ const [speakerMuted, setSpeakerMuted] = useState(false); const [speakerGain, setSpeakerGain] = useState(DEFAULT_REMOTE_AUDIO_GAIN); const [spaceWarning, setSpaceWarning] = useState(null); + /** Set when the machine is short on memory; the string is what the user reads. */ + const [memoryNotice, setMemoryNotice] = useState(null); const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 }); const [waylandInputDiagnosticsDismissed, setWaylandInputDiagnosticsDismissed] = useState(false); @@ -851,6 +853,31 @@ export function CapturePreview({ return unsubscribe; }, [endSession]); + // The machine is running out of memory. + // + // Main has already finalised the recording and stopped any egress by the time + // 'critical' arrives; capture and the WebRTC publication live here, so they + // have to be dropped here. Ending the session beats being the reason someone + // has to hold their power button — say why, so it does not look like a crash. + useEffect(() => { + const api = getElectronAPI(); + const unsubscribeWarning = api.on('resource:warning', ({ availableMb }) => { + setMemoryNotice( + `This machine is low on memory (${String(availableMb)} MB free). Closing a few apps will keep the share stable.` + ); + }); + const unsubscribeCritical = api.on('resource:critical', ({ availableMb }) => { + setMemoryNotice( + `Sharing stopped: this machine was almost out of memory (${String(availableMb)} MB free). Any recording was saved.` + ); + void endSession(); + }); + return () => { + unsubscribeWarning(); + unsubscribeCritical(); + }; + }, [endSession]); + // Poll for participant updates while session is active const refreshSessionRef = useRef(refreshSession); refreshSessionRef.current = refreshSession; @@ -1500,6 +1527,14 @@ export function CapturePreview({ )} + {/* Memory pressure */} + {memoryNotice && ( +
+ + {memoryNotice} +
+ )} + {/* Space warning */} {spaceWarning !== null && (
diff --git a/apps/desktop/src/renderer/hooks/useScreenCameraCompositor.ts b/apps/desktop/src/renderer/hooks/useScreenCameraCompositor.ts index 810d2e4f..5c05dfb1 100644 --- a/apps/desktop/src/renderer/hooks/useScreenCameraCompositor.ts +++ b/apps/desktop/src/renderer/hooks/useScreenCameraCompositor.ts @@ -9,6 +9,7 @@ import { useEffect, useState, type RefObject } from 'react'; import { clamp } from '@/lib/containRect'; +import { fitWithin, qualityResolution } from '@/lib/captureQuality'; export interface BubbleGeometry { /** Horizontal center as a fraction (0-1) of the frame width. */ @@ -100,10 +101,26 @@ export function useScreenCameraCompositor({ } const canvas = document.createElement('canvas'); - // Size the canvas to the screen track's native resolution so the recording is full quality. + // Size the canvas to the user's quality setting, not the screen track's + // native resolution. + // + // The track is asked to downscale with `ideal` only, which Chromium's + // desktop capturer is free to ignore and does — so a 4K monitor hands us a + // 3840x2160 track even when the user picked 1080p. Sizing off that meant + // compositing 8.3M pixels per frame, 33MB of clear+draw, for output that + // was going to be encoded at 1080p anyway. Honour the setting here, where + // it actually binds, and keep the source's aspect ratio so nothing + // stretches. const screenSettings = screenStream.getVideoTracks()[0].getSettings(); - canvas.width = screenSettings.width ?? FALLBACK_WIDTH; - canvas.height = screenSettings.height ?? FALLBACK_HEIGHT; + const { width, height } = fitWithin( + { + width: screenSettings.width ?? FALLBACK_WIDTH, + height: screenSettings.height ?? FALLBACK_HEIGHT, + }, + qualityResolution() + ); + canvas.width = width; + canvas.height = height; const ctx = canvas.getContext('2d'); // captureStream is unavailable in some test environments — bail out gracefully. diff --git a/apps/desktop/src/renderer/lib/captureQuality.test.ts b/apps/desktop/src/renderer/lib/captureQuality.test.ts new file mode 100644 index 00000000..469175f9 --- /dev/null +++ b/apps/desktop/src/renderer/lib/captureQuality.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + CAPTURE_RESOLUTION, + DEFAULT_QUALITY, + fitWithin, + qualityResolution, + readQualitySetting, +} from './captureQuality'; + +describe('fitWithin', () => { + const hd = CAPTURE_RESOLUTION['1080p']; + + it('shrinks a 4K source into the 1080p budget', () => { + // The case that matters: Chromium hands back a native 4K desktop track + // even when the user asked for 1080p. + expect(fitWithin({ width: 3840, height: 2160 }, hd)).toEqual({ width: 1920, height: 1080 }); + }); + + it('leaves a source already within budget alone', () => { + // Upscaling would buy no detail and cost real pixels. + expect(fitWithin({ width: 1280, height: 720 }, hd)).toEqual({ width: 1280, height: 720 }); + }); + + it('preserves aspect ratio for an ultrawide source', () => { + const fitted = fitWithin({ width: 3440, height: 1440 }, hd); + expect(fitted.width).toBe(1920); + // 1440 * (1920/3440) = 803.7, floored to even. + expect(fitted.height).toBe(802); + expect(Math.abs(fitted.width / fitted.height - 3440 / 1440)).toBeLessThan(0.05); + }); + + it('preserves aspect ratio for a portrait source', () => { + const fitted = fitWithin({ width: 1080, height: 1920 }, hd); + expect(fitted.height).toBe(1080); + expect(fitted.width).toBe(606); + }); + + it('keeps both axes even for 4:2:0 chroma', () => { + const fitted = fitWithin({ width: 1365, height: 767 }, hd); + expect(fitted.width % 2).toBe(0); + expect(fitted.height % 2).toBe(0); + }); + + it('never exceeds the budget it was given', () => { + // Rounding to the nearest multiple of 16 used to turn a 1080 bound into + // 1088 — overshooting the limit the caller asked for. + for (const source of [ + { width: 3840, height: 2160 }, + { width: 2560, height: 1440 }, + { width: 3440, height: 1440 }, + { width: 1080, height: 1920 }, + ]) { + const fitted = fitWithin(source, hd); + expect(fitted.width).toBeLessThanOrEqual(hd.width); + expect(fitted.height).toBeLessThanOrEqual(hd.height); + } + }); + + it('never returns a zero dimension for a degenerate source', () => { + expect(fitWithin({ width: 0, height: 0 }, hd)).toEqual(hd); + const sliver = fitWithin({ width: 3840, height: 1 }, hd); + expect(sliver.width).toBeGreaterThan(0); + expect(sliver.height).toBeGreaterThan(0); + }); +}); + +describe('readQualitySetting', () => { + const store = new Map(); + + beforeEach(() => { + store.clear(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => store.set(k, v), + removeItem: (k: string) => store.delete(k), + clear: () => store.clear(), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('defaults when nothing is stored', () => { + expect(readQualitySetting()).toBe(DEFAULT_QUALITY); + }); + + it('reads the stored quality', () => { + store.set('pairux-settings', JSON.stringify({ recording: { defaultQuality: '720p' } })); + expect(readQualitySetting()).toBe('720p'); + expect(qualityResolution()).toEqual({ width: 1280, height: 720 }); + }); + + it('ignores a quality it does not recognise', () => { + store.set('pairux-settings', JSON.stringify({ recording: { defaultQuality: '8k' } })); + expect(readQualitySetting()).toBe(DEFAULT_QUALITY); + }); + + it('survives corrupt JSON rather than failing the capture', () => { + store.set('pairux-settings', '{not json'); + expect(readQualitySetting()).toBe(DEFAULT_QUALITY); + }); +}); diff --git a/apps/desktop/src/renderer/lib/captureQuality.ts b/apps/desktop/src/renderer/lib/captureQuality.ts new file mode 100644 index 00000000..c4b91ab9 --- /dev/null +++ b/apps/desktop/src/renderer/lib/captureQuality.ts @@ -0,0 +1,79 @@ +/** + * The one place that answers "how big should captured video be?". + * + * The user's quality setting used to be applied in exactly one spot — an + * `applyConstraints({ width: { ideal } })` on the capture track — and `ideal` + * is a hint a source is free to ignore. Chromium's desktop capturer routinely + * does: a 4K monitor keeps producing a 3840x2160 track no matter what the + * setting says. Anything downstream that sized itself from the track's real + * settings therefore worked in native resolution while the user believed they + * had chosen 1080p, which on a 4K display is 4x the pixels per frame. + */ + +/** Standard presets. */ +export const CAPTURE_RESOLUTION: Record = { + '720p': { width: 1280, height: 720 }, + '1080p': { width: 1920, height: 1080 }, + '4k': { width: 3840, height: 2160 }, +}; + +export const DEFAULT_QUALITY = '1080p'; + +const SETTINGS_STORAGE_KEY = 'pairux-settings'; + +/** The user's chosen quality, or the default when unset or unreadable. */ +export function readQualitySetting(): string { + try { + const saved = localStorage.getItem(SETTINGS_STORAGE_KEY); + if (!saved) return DEFAULT_QUALITY; + const parsed = JSON.parse(saved) as { recording?: { defaultQuality?: string } }; + const quality = parsed.recording?.defaultQuality; + if (quality && quality in CAPTURE_RESOLUTION) return quality; + } catch { + // Fall through to the default. + } + return DEFAULT_QUALITY; +} + +/** The pixel budget for the user's current quality setting. */ +export function qualityResolution(quality = readQualitySetting()): { + width: number; + height: number; +} { + return CAPTURE_RESOLUTION[quality] ?? CAPTURE_RESOLUTION[DEFAULT_QUALITY]; +} + +/** + * Shrink `source` to fit inside `bound` without changing its aspect ratio. + * + * Only ever scales down: a source already within budget is returned untouched + * rather than upscaled into extra work for no detail. + * + * Both axes are floored to an even number. Even is what 4:2:0 chroma + * subsampling actually requires, and flooring is what keeps the result inside + * the budget — rounding to the nearest multiple of 16, which is what the + * capture presets claim to do, turns a 1080 bound into 1088 and quietly + * overshoots the very limit being applied. The presets themselves are not + * 16-aligned either (1080 is not a multiple of 16), so that rule was never + * really in force. + */ +export function fitWithin( + source: { width: number; height: number }, + bound: { width: number; height: number } +): { width: number; height: number } { + const { width, height } = source; + if (width <= 0 || height <= 0) return alignEven(bound); + + const scale = Math.min(bound.width / width, bound.height / height, 1); + return alignEven({ width: width * scale, height: height * scale }); +} + +function alignEven({ width, height }: { width: number; height: number }): { + width: number; + height: number; +} { + return { + width: Math.max(2, Math.floor(width / 2) * 2), + height: Math.max(2, Math.floor(height / 2) * 2), + }; +} diff --git a/apps/desktop/src/renderer/routes/home.tsx b/apps/desktop/src/renderer/routes/home.tsx index f8b66d4d..8c3c32a6 100644 --- a/apps/desktop/src/renderer/routes/home.tsx +++ b/apps/desktop/src/renderer/routes/home.tsx @@ -17,36 +17,26 @@ import { useAuthStore } from '@/stores/auth'; import type { CaptureSource, Session } from '@pairux/shared-types'; import { VOICE_AUDIO_CONSTRAINTS } from '@pairux/shared-types'; import type { DisplayServer } from '../../preload/api'; +import { qualityResolution, readQualitySetting } from '@/lib/captureQuality'; -// Standard resolution presets (all macroblock-aligned to prevent VP9 green bar artifacts) -const CAPTURE_RESOLUTION: Record = { - '720p': { width: 1280, height: 720 }, - '1080p': { width: 1920, height: 1080 }, - '4k': { width: 3840, height: 2160 }, -}; +/** + * Frames per second to ask a screen capture for. + * + * Screen content is mostly static, and every extra frame is one more encode of + * a full-resolution desktop — on the host, while it is also compositing, and + * possibly also writing a recording and feeding ffmpeg. Asking for up to 60 + * doubled the encoder's work for detail nobody watching a shared editor can + * see. The canvas compositor already samples at 30. + */ +const CAPTURE_FRAME_RATE = 30; /** * Read the user's quality setting and force the video track to that standard resolution. * This prevents green bar artifacts from non-macroblock-aligned resolutions. */ async function constrainTrackToQualitySetting(track: MediaStreamTrack): Promise { - let quality = '1080p'; - try { - const saved = localStorage.getItem('pairux-settings'); - if (saved) { - const parsed = JSON.parse(saved) as { recording?: { defaultQuality?: string } }; - if ( - parsed.recording?.defaultQuality && - parsed.recording.defaultQuality in CAPTURE_RESOLUTION - ) { - quality = parsed.recording.defaultQuality; - } - } - } catch { - // Use default - } - - const target = CAPTURE_RESOLUTION[quality] ?? CAPTURE_RESOLUTION['1080p']; + const quality = readQualitySetting(); + const target = qualityResolution(quality); const settings = track.getSettings(); try { @@ -198,6 +188,7 @@ export function HomePage() { console.log('[Renderer] Display server:', displayServer); let mediaStream: MediaStream; + const bound = qualityResolution(); if (isWayland) { // Wayland: Use getDisplayMedia with PipeWire portal @@ -209,9 +200,9 @@ export function HomePage() { mediaStream = await navigator.mediaDevices.getDisplayMedia({ video: { displaySurface: source.type === 'screen' ? 'monitor' : 'window', - width: { ideal: 1920, max: 3840 }, - height: { ideal: 1080, max: 2160 }, - frameRate: { ideal: 30, max: 60 }, + width: { ideal: bound.width, max: bound.width }, + height: { ideal: bound.height, max: bound.height }, + frameRate: { ideal: CAPTURE_FRAME_RATE, max: CAPTURE_FRAME_RATE }, }, audio: false, }); @@ -225,12 +216,18 @@ export function HomePage() { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: source.id, - minWidth: 1280, - maxWidth: 3840, - minHeight: 720, - maxHeight: 2160, + // Bounded by the quality setting rather than pinned at 4K60. + // These mandatory maxima are the only thing that constrains a + // desktopCapturer source: the applyConstraints() call below + // asks with `ideal`, which it may ignore. `minWidth`/`minHeight` + // are capped alongside so a 720p setting cannot invert the + // range and overconstrain the request. + minWidth: Math.min(1280, bound.width), + maxWidth: bound.width, + minHeight: Math.min(720, bound.height), + maxHeight: bound.height, minFrameRate: 15, - maxFrameRate: 60, + maxFrameRate: CAPTURE_FRAME_RATE, }, }, }); @@ -294,11 +291,16 @@ export function HomePage() { // portal's own picker decides. await getElectronAPI().invoke('capture:setPreferredSource', { sourceId: null }); + const captureBound = qualityResolution(); const mediaStream = await navigator.mediaDevices.getDisplayMedia({ video: { - width: { ideal: 1920, max: 3840 }, - height: { ideal: 1080, max: 2160 }, - frameRate: { ideal: 30, max: 60 }, + // `max` is what actually binds here. applyConstraints() below can + // only ask with `ideal`, which the desktop capturer may ignore, so + // a capture left unbounded at this point stays native-resolution + // for the whole session. + width: { ideal: captureBound.width, max: captureBound.width }, + height: { ideal: captureBound.height, max: captureBound.height }, + frameRate: { ideal: CAPTURE_FRAME_RATE, max: CAPTURE_FRAME_RATE }, }, audio: false, }); @@ -389,11 +391,16 @@ export function HomePage() { // portal dialog, so no preference to honour. await getElectronAPI().invoke('capture:setPreferredSource', { sourceId: null }); + const captureBound = qualityResolution(); const mediaStream = await navigator.mediaDevices.getDisplayMedia({ video: { - width: { ideal: 1920, max: 3840 }, - height: { ideal: 1080, max: 2160 }, - frameRate: { ideal: 30, max: 60 }, + // `max` is what actually binds here. applyConstraints() below can + // only ask with `ideal`, which the desktop capturer may ignore, so + // a capture left unbounded at this point stays native-resolution + // for the whole session. + width: { ideal: captureBound.width, max: captureBound.width }, + height: { ideal: captureBound.height, max: captureBound.height }, + frameRate: { ideal: CAPTURE_FRAME_RATE, max: CAPTURE_FRAME_RATE }, }, audio: false, });