Skip to content
Merged
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
163 changes: 163 additions & 0 deletions apps/desktop/src/main/health/resourceGuard.test.ts
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof startResourceGuard>[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();
});
});
171 changes: 171 additions & 0 deletions apps/desktop/src/main/health/resourceGuard.ts
Original file line number Diff line number Diff line change
@@ -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);
};
}
Loading
Loading