diff --git a/e2e-tests/performance_monitor.spec.ts b/e2e-tests/performance_monitor.spec.ts index b9a74d895c..7f3e19f0d0 100644 --- a/e2e-tests/performance_monitor.spec.ts +++ b/e2e-tests/performance_monitor.spec.ts @@ -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); + expect(settings.lastKnownPerformance.diskUsedMB).toBeGreaterThan(0); + 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(); diff --git a/src/lib/schemas.test.ts b/src/lib/schemas.test.ts index d2ebe05ddb..7cb8205c39 100644 --- a/src/lib/schemas.test.ts +++ b/src/lib/schemas.test.ts @@ -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 }, diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index f69c291f0f..8d99ff160e 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -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(), diff --git a/src/utils/crash_telemetry_fields.test.ts b/src/utils/crash_telemetry_fields.test.ts index 6fd0a361fb..8c6c7cd13a 100644 --- a/src/utils/crash_telemetry_fields.test.ts +++ b/src/utils/crash_telemetry_fields.test.ts @@ -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, @@ -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)) { @@ -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(); }); diff --git a/src/utils/crash_telemetry_fields.ts b/src/utils/crash_telemetry_fields.ts index c81ee7457f..dee8f04862 100644 --- a/src/utils/crash_telemetry_fields.ts +++ b/src/utils/crash_telemetry_fields.ts @@ -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, diff --git a/src/utils/disk_usage.test.ts b/src/utils/disk_usage.test.ts new file mode 100644 index 0000000000..973024c85f --- /dev/null +++ b/src/utils/disk_usage.test.ts @@ -0,0 +1,81 @@ +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 { + 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 every failure, not just the first", () => { + statfsSync.mockImplementation(() => { + throw new Error("ENOENT"); + }); + + getDiskUsageMB("/missing"); + getDiskUsageMB("/missing"); + + // A repeating failure is itself diagnostic, and only a recent line + // survives in the last-N-lines view that bug reports include. + expect(errorLog).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/utils/disk_usage.ts b/src/utils/disk_usage.ts new file mode 100644 index 0000000000..1939d59257 --- /dev/null +++ b/src/utils/disk_usage.ts @@ -0,0 +1,35 @@ +import fs from "node:fs"; +import log from "electron-log"; + +const logger = log.scope("disk-usage"); + +const BYTES_PER_MB = 1024 * 1024; + +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), + usedMB: toMB(stats.blocks - stats.bfree), + availableMB: toMB(stats.bavail), + }; + } catch (error) { + logger.error(`Failed to read disk usage for ${targetPath}:`, error); + return null; + } +} diff --git a/src/utils/performance_monitor.ts b/src/utils/performance_monitor.ts index 3b276a9322..52ae952d18 100644 --- a/src/utils/performance_monitor.ts +++ b/src/utils/performance_monitor.ts @@ -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"); @@ -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()); 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}%`, @@ -271,6 +277,11 @@ function capturePerformanceMetrics() { systemMemoryUsageMB: systemMemory.usedMemoryMB, systemMemoryTotalMB: systemMemory.totalMemoryMB, systemCpuPercent, + ...(diskUsage && { + diskTotalMB: diskUsage.totalMB, + diskUsedMB: diskUsage.usedMB, + diskAvailableMB: diskUsage.availableMB, + }), heapUsedMB, heapLimitMB, ...(processWorkingSetsMB && { processWorkingSetsMB }),