Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
17 changes: 17 additions & 0 deletions e2e-tests/performance_monitor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,23 @@ testWithConfig({})(
expect(
settings.lastKnownPerformance.systemCpuPercent,
).toBeGreaterThanOrEqual(0);
// statfs works on every platform we ship, so a real capture always has
// disk figures. The fields are dropped on a read failure, so assert
// presence up front: an absent field is the regression to catch.
expect(settings.lastKnownPerformance).toMatchObject({
diskTotalMB: expect.any(Number),
diskUsedMB: expect.any(Number),
diskAvailableMB: expect.any(Number),
});
// Used never exceeds total; available can trail both.
expect(settings.lastKnownPerformance.diskTotalMB).toBeGreaterThan(0);
Comment thread
RyanGroch marked this conversation as resolved.
expect(settings.lastKnownPerformance.diskUsedMB).toBeGreaterThan(0);
Comment thread
RyanGroch marked this conversation as resolved.
Comment thread
RyanGroch marked this conversation as resolved.
expect(settings.lastKnownPerformance.diskUsedMB).toBeLessThanOrEqual(
settings.lastKnownPerformance.diskTotalMB,
);
expect(settings.lastKnownPerformance.diskAvailableMB).toBeLessThanOrEqual(
settings.lastKnownPerformance.diskTotalMB,
);

// Verify the timestamp is recent (within the last minute)
const now = Date.now();
Expand Down
3 changes: 3 additions & 0 deletions src/lib/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ describe("StoredUserSettingsSchema lastKnownPerformance", () => {
systemMemoryUsageMB: 8000,
systemMemoryTotalMB: 16000,
systemCpuPercent: 33,
diskTotalMB: 476000,
diskUsedMB: 401000,
diskAvailableMB: 51000,
heapUsedMB: 512,
heapLimitMB: 4144,
processWorkingSetsMB: { browser: 400, tab: 900, utility: 300 },
Expand Down
6 changes: 6 additions & 0 deletions src/lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,12 @@ export const LastKnownPerformanceSchema = z.object({
systemMemoryUsageMB: z.number().optional(),
systemMemoryTotalMB: z.number().optional(),
systemCpuPercent: z.number().optional(),
// Capacity of the volume holding the user data directory. diskUsedMB counts
// every allocated block; diskAvailableMB excludes space the platform holds
// back (root reserve, quota), so the two need not sum to diskTotalMB.
diskTotalMB: z.number().optional(),
diskUsedMB: z.number().optional(),
diskAvailableMB: z.number().optional(),
// Main process V8 heap, from v8.getHeapStatistics().
heapUsedMB: z.number().optional(),
heapLimitMB: z.number().optional(),
Expand Down
9 changes: 9 additions & 0 deletions src/utils/crash_telemetry_fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ describe("crashPerformanceEventFields", () => {
memoryUsageMB: 400,
heapUsedMB: 512,
heapLimitMB: 4144,
diskTotalMB: 476000,
diskUsedMB: 401000,
diskAvailableMB: 51000,
processWorkingSetsMB: { browser: 400, tab: 900, zygote: 30, unknown: 20 },
activity: {
activeStreams: 1,
Expand Down Expand Up @@ -45,6 +48,11 @@ describe("crashPerformanceEventFields", () => {
expect(fields.peak_active_streams).toBe(2);
expect(fields.peak_ts_utility_process).toBeNull();
expect(fields.peak_heap_used_mb).toBe(1024);
expect(fields.last_known_disk_total_mb).toBe(476000);
expect(fields.last_known_disk_used_mb).toBe(401000);
// Reported separately from used because space the platform withholds
// sits between them: used + available is short of total.
expect(fields.last_known_disk_available_mb).toBe(51000);

// No object-valued properties: PostHog cannot filter nested JSON.
for (const value of Object.values(fields)) {
Expand All @@ -60,6 +68,7 @@ describe("crashPerformanceEventFields", () => {

expect(fields.last_known_memory_mb).toBe(400);
expect(fields.last_known_working_set_browser_mb).toBeUndefined();
expect(fields.last_known_disk_total_mb).toBeUndefined();
expect(fields.last_known_active_streams).toBeUndefined();
expect(fields.peak_active_streams).toBeUndefined();
});
Expand Down
3 changes: 3 additions & 0 deletions src/utils/crash_telemetry_fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export function crashPerformanceEventFields(
last_known_system_memory_mb: perf.systemMemoryUsageMB,
last_known_system_memory_total_mb: perf.systemMemoryTotalMB,
last_known_system_cpu_pct: perf.systemCpuPercent,
last_known_disk_total_mb: perf.diskTotalMB,
last_known_disk_used_mb: perf.diskUsedMB,
last_known_disk_available_mb: perf.diskAvailableMB,
last_known_snapshot_timestamp: perf.timestamp,
time_since_last_heartbeat_ms: Date.now() - perf.timestamp,
last_known_heap_used_mb: perf.heapUsedMB,
Expand Down
103 changes: 103 additions & 0 deletions src/utils/disk_usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import fs from "node:fs";
import { getDiskUsageMB } from "@/utils/disk_usage";

const { errorLog } = vi.hoisted(() => ({ errorLog: vi.fn() }));
vi.mock("electron-log", () => ({
default: { scope: () => ({ error: errorLog }) },
}));

vi.mock("node:fs", () => ({
default: { statfsSync: vi.fn() },
}));

const statfsSync = vi.mocked(fs.statfsSync);

// 4KiB blocks: 262144 total = 1024MB, 65536 free = 256MB, 32768 available
// to non-root = 128MB. The gap between free and available is the reserve.
function statfsResult(overrides: Partial<fs.StatsFs> = {}): fs.StatsFs {
return {
type: 61267,
bsize: 4096,
blocks: 262144,
bfree: 65536,
bavail: 32768,
files: 0,
ffree: 0,
...overrides,
} as fs.StatsFs;
}

describe("getDiskUsageMB", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("converts blocks to MB and reports used and available separately", () => {
statfsSync.mockReturnValue(statfsResult());

expect(getDiskUsageMB("/some/path")).toEqual({
totalMB: 1024,
// Every allocated block, including the root reserve.
usedMB: 768,
// Excludes the reserve, so used + available is short of total.
availableMB: 128,
});
expect(statfsSync).toHaveBeenCalledExactlyOnceWith("/some/path");
});

it("scales with the filesystem's block size", () => {
statfsSync.mockReturnValue(
statfsResult({ bsize: 1024, blocks: 2048, bfree: 1024, bavail: 1024 }),
);

expect(getDiskUsageMB("/some/path")).toEqual({
totalMB: 2,
usedMB: 1,
availableMB: 1,
});
});

it("returns null when the path cannot be read", () => {
statfsSync.mockImplementation(() => {
throw new Error("ENOENT");
});

expect(getDiskUsageMB("/missing")).toBeNull();
});

it("logs a failure once rather than on every call", async () => {
// Fresh module so the once-per-process flag starts unset.
vi.resetModules();
const { getDiskUsageMB: freshGetDiskUsageMB } =
await import("@/utils/disk_usage");
statfsSync.mockImplementation(() => {
throw new Error("ENOENT");
});

freshGetDiskUsageMB("/missing");
freshGetDiskUsageMB("/missing");
freshGetDiskUsageMB("/missing");

expect(errorLog).toHaveBeenCalledTimes(1);
});

it("stays quiet on a later failure even after a reading succeeds", async () => {
vi.resetModules();
const { getDiskUsageMB: freshGetDiskUsageMB } =
await import("@/utils/disk_usage");
const throwENOENT = () => {
throw new Error("ENOENT");
};

statfsSync.mockImplementation(throwENOENT);
freshGetDiskUsageMB("/missing");
statfsSync.mockReturnValue(statfsResult());
freshGetDiskUsageMB("/some/path");
statfsSync.mockImplementation(throwENOENT);
freshGetDiskUsageMB("/missing");

// A volume that flaps would otherwise re-arm the log on every recovery.
expect(errorLog).toHaveBeenCalledTimes(1);
});
});
42 changes: 42 additions & 0 deletions src/utils/disk_usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import fs from "node:fs";
import log from "electron-log";

const logger = log.scope("disk-usage");

const BYTES_PER_MB = 1024 * 1024;

// This runs every 30s, and getSystemDebugInfo shows only the last 20 warn+
// lines, so a repeating error would push the crash warnings out of view.
let hasLoggedFailure = false;

export interface DiskUsageMB {
totalMB: number;
usedMB: number;
availableMB: number;
}

/**
* Capacity of the filesystem holding `targetPath`, from one statfs syscall.
* usedMB counts every allocated block, while availableMB is what this user
* can actually write and is lower wherever the platform holds space back —
* a root reserve, a per-user quota. Returns null when the path is unreadable
* so callers can omit the fields rather than report a zero.
*/
export function getDiskUsageMB(targetPath: string): DiskUsageMB | null {
try {
const stats = fs.statfsSync(targetPath);
const toMB = (blocks: number) =>
Math.round((blocks * stats.bsize) / BYTES_PER_MB);
return {
totalMB: toMB(stats.blocks),
Comment thread
RyanGroch marked this conversation as resolved.
usedMB: toMB(stats.blocks - stats.bfree),
availableMB: toMB(stats.bavail),
Comment thread
RyanGroch marked this conversation as resolved.
};
} catch (error) {
if (!hasLoggedFailure) {
hasLoggedFailure = true;
logger.error(`Failed to read disk usage for ${targetPath}:`, error);
}
return null;
Comment thread
RyanGroch marked this conversation as resolved.
}
}
11 changes: 11 additions & 0 deletions src/utils/performance_monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
import { getActiveStreamCount } from "../ipc/handlers/chat_stream_handlers";
import { runningApps } from "../ipc/utils/process_manager";
import { typescriptUtilityProcessScheduler } from "../ipc/processors/typescript_utility_process_scheduler";
import { getUserDataPath } from "../paths/paths";
import { getDiskUsageMB } from "./disk_usage";

const logger = log.scope("performance-monitor");

Expand Down Expand Up @@ -231,6 +233,10 @@ function capturePerformanceMetrics() {
: 0;
const kernelPeakRssMB = getKernelPeakRssMB();
const activity = snapshotActivity();
// The user data volume is the system volume, which is the disk we want
// to measure. The apps folder is user-configurable and can sit on a
// different drive entirely.
const diskUsage = getDiskUsageMB(getUserDataPath());
Comment thread
RyanGroch marked this conversation as resolved.
Comment thread
RyanGroch marked this conversation as resolved.
Comment thread
RyanGroch marked this conversation as resolved.

logger.debug(
`Performance: Memory=${memoryUsageMB}MB, Heap=${heapUsedMB}/${heapLimitMB}MB, All Processes=${allProcessesMemoryMB ?? "?"}MB, CPU=${cpuUsagePercent}%, System Memory=${systemMemory.usedMemoryMB}/${systemMemory.totalMemoryMB}MB (${systemMemory.usagePercent}%), System CPU=${systemCpuPercent}%`,
Expand Down Expand Up @@ -271,6 +277,11 @@ function capturePerformanceMetrics() {
systemMemoryUsageMB: systemMemory.usedMemoryMB,
systemMemoryTotalMB: systemMemory.totalMemoryMB,
systemCpuPercent,
...(diskUsage && {
diskTotalMB: diskUsage.totalMB,
diskUsedMB: diskUsage.usedMB,
diskAvailableMB: diskUsage.availableMB,
Comment thread
RyanGroch marked this conversation as resolved.
}),
heapUsedMB,
heapLimitMB,
...(processWorkingSetsMB && { processWorkingSetsMB }),
Expand Down
Loading