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/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/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 9c69ced3..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. */ @@ -29,6 +30,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( @@ -88,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. @@ -104,7 +133,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 +172,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 +184,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/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, }); 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