diff --git a/src/main/index.ts b/src/main/index.ts index 746a21c2..c3069f59 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -191,6 +191,10 @@ import { markRendererUnavailable, safeSendToRenderer, } from './utils/safeWebContentsSend'; +import { + captureStartupMemorySnapshot, + formatStartupMemorySnapshot, +} from './utils/startupTelemetry'; import { syncTelemetryFlag } from './sentry'; import { setCodexRuntimeMainWindow } from './ipc/codexRuntime'; import { @@ -236,7 +240,12 @@ import { } from './services'; import type { FileChangeEvent } from '@main/types'; -import type { AppStartupStatus, AppStartupStep, TeamChangeEvent } from '@shared/types'; +import type { + AppStartupMemorySnapshot, + AppStartupStatus, + AppStartupStep, + TeamChangeEvent, +} from '@shared/types'; const logger = createLogger('App'); const appStartedAtMs = Date.now(); @@ -936,12 +945,14 @@ const STARTUP_CLI_WARMUP_DELAY_MS = 90_000; const STARTUP_BACKGROUND_SERVICE_DELAY_MS = 5_000; const STARTUP_RECOVERY_CONCURRENCY = 1; const appStartupStartedAt = Date.now(); +const initialStartupMemory = captureStartupMemorySnapshot(); let appStartupSteps: AppStartupStep[] = [ { phase: 'boot', message: 'Starting Agent Teams AI...', startedAt: appStartupStartedAt, updatedAt: appStartupStartedAt, + memoryAtStart: initialStartupMemory, }, ]; let appStartupStatus: AppStartupStatus = { @@ -951,6 +962,7 @@ let appStartupStatus: AppStartupStatus = { error: null, startedAt: appStartupStartedAt, updatedAt: appStartupStartedAt, + memory: initialStartupMemory, steps: appStartupSteps, }; @@ -1001,7 +1013,11 @@ function cloneStartupSteps(): AppStartupStep[] { return appStartupSteps.map((step) => ({ ...step })); } -function updateStartupTimeline(update: Partial, now: number): void { +function updateStartupTimeline( + update: Partial, + now: number, + memory: AppStartupMemorySnapshot +): void { if (!update.phase && !update.message) { return; } @@ -1015,12 +1031,14 @@ function updateStartupTimeline(update: Partial, now: number): current.finishedAt = now; current.durationMs = now - current.startedAt; current.updatedAt = now; + current.memoryAtEnd = memory; } appStartupSteps.push({ phase, message, startedAt: now, updatedAt: now, + memoryAtStart: memory, }); if (appStartupSteps.length > 32) { appStartupSteps = appStartupSteps.slice(-32); @@ -1031,7 +1049,7 @@ function updateStartupTimeline(update: Partial, now: number): } } -function finishCurrentStartupStep(now: number): void { +function finishCurrentStartupStep(now: number, memory: AppStartupMemorySnapshot): void { const current = appStartupSteps[appStartupSteps.length - 1]; if (!current || current.finishedAt) { return; @@ -1039,20 +1057,30 @@ function finishCurrentStartupStep(now: number): void { current.finishedAt = now; current.durationMs = now - current.startedAt; current.updatedAt = now; + current.memoryAtEnd = memory; } function publishStartupStatus(update: Partial): void { const now = Date.now(); - updateStartupTimeline(update, now); + const memory = captureStartupMemorySnapshot(); + updateStartupTimeline(update, now, memory); if (update.ready === true || update.error) { - finishCurrentStartupStep(now); + finishCurrentStartupStep(now, memory); } appStartupStatus = { ...appStartupStatus, ...update, updatedAt: now, + memory, steps: cloneStartupSteps(), }; + if (update.phase || update.ready === true || update.error) { + logger.info( + `[startup] phase=${appStartupStatus.phase} ready=${appStartupStatus.ready} elapsedMs=${ + now - appStartupStartedAt + } ${formatStartupMemorySnapshot(memory)}` + ); + } safeSendToRenderer(mainWindow, APP_STARTUP_PROGRESS, appStartupStatus); } diff --git a/src/main/services/infrastructure/CrossPlatformFileChangeSource.ts b/src/main/services/infrastructure/CrossPlatformFileChangeSource.ts index 636fa274..21965669 100644 --- a/src/main/services/infrastructure/CrossPlatformFileChangeSource.ts +++ b/src/main/services/infrastructure/CrossPlatformFileChangeSource.ts @@ -14,11 +14,19 @@ export interface WatcherLifecycle { isCurrent: () => boolean; } +export interface PollSnapshotResult { + files: Map; + cycleComplete: boolean; + deleteSafe?: boolean; +} + +type PollSnapshot = Map | PollSnapshotResult; + export interface CrossPlatformFileChangeSourceOptions { name: string; pollIntervalMs: number; createWatcher?: (lifecycle: WatcherLifecycle) => Promise | CloseableWatcher; - collectPollSnapshot: () => Promise>; + collectPollSnapshot: () => Promise; emitPolledChange: (eventType: PollingChangeEventType, relativePath: string) => void; isOwnerActive: () => boolean; isWatchLimitError: (error: unknown) => boolean; @@ -34,6 +42,7 @@ export class CrossPlatformFileChangeSource { private pollingGenerationInProgress: number | null = null; private pollingPrimed = false; private pollSnapshot = new Map(); + private partialPollSnapshot = new Map(); private closedGeneration: number | null = null; private rejectedGeneration: number | null = null; private generation = 0; @@ -180,6 +189,7 @@ export class CrossPlatformFileChangeSource { this.pollingGenerationInProgress = null; this.pollingPrimed = false; this.pollSnapshot.clear(); + this.partialPollSnapshot.clear(); const timer = this.pollingTimer; this.pollingTimer = null; @@ -265,34 +275,51 @@ export class CrossPlatformFileChangeSource { } private async pollForChanges(expectedGeneration: number): Promise { - const nextSnapshot = await this.options.collectPollSnapshot(); + const nextSnapshot = normalizePollSnapshot(await this.options.collectPollSnapshot()); if (expectedGeneration !== this.generation || !this.options.isOwnerActive()) { return; } + this.mergePartialPollSnapshot(nextSnapshot.files); + if (!this.pollingPrimed) { - logger.info(`${this.options.name} polling baseline captured`); - this.pollSnapshot = nextSnapshot; - this.pollingPrimed = true; + if (nextSnapshot.cycleComplete) { + logger.info(`${this.options.name} polling baseline captured`); + this.pollSnapshot = this.partialPollSnapshot; + this.partialPollSnapshot = new Map(); + this.pollingPrimed = true; + } return; } - for (const [relativePath, fingerprint] of nextSnapshot) { + for (const [relativePath, fingerprint] of nextSnapshot.files) { const previous = this.pollSnapshot.get(relativePath); if (previous === undefined) { this.options.emitPolledChange('rename', relativePath); } else if (previous !== fingerprint) { this.options.emitPolledChange('change', relativePath); } + this.pollSnapshot.set(relativePath, fingerprint); } - for (const relativePath of this.pollSnapshot.keys()) { - if (!nextSnapshot.has(relativePath)) { - this.options.emitPolledChange('rename', relativePath); + if (nextSnapshot.cycleComplete) { + const completedSnapshot = this.partialPollSnapshot; + if (nextSnapshot.deleteSafe !== false) { + for (const relativePath of this.pollSnapshot.keys()) { + if (!completedSnapshot.has(relativePath)) { + this.options.emitPolledChange('rename', relativePath); + } + } } + this.pollSnapshot = completedSnapshot; + this.partialPollSnapshot = new Map(); } + } - this.pollSnapshot = nextSnapshot; + private mergePartialPollSnapshot(files: Map): void { + for (const [relativePath, fingerprint] of files) { + this.partialPollSnapshot.set(relativePath, fingerprint); + } } private async closeWatcher(watcher: CloseableWatcher): Promise { @@ -318,3 +345,14 @@ export class CrossPlatformFileChangeSource { ); } } + +function normalizePollSnapshot(snapshot: PollSnapshot): PollSnapshotResult { + if (snapshot instanceof Map) { + return { + files: snapshot, + cycleComplete: true, + deleteSafe: true, + }; + } + return snapshot; +} diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index fc40281d..9e2ff08e 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -33,7 +33,10 @@ import { projectPathResolver } from '../discovery/ProjectPathResolver'; import { errorDetector } from '../error/ErrorDetector'; import { ConfigManager } from './ConfigManager'; -import { CrossPlatformFileChangeSource } from './CrossPlatformFileChangeSource'; +import { + CrossPlatformFileChangeSource, + type PollSnapshotResult, +} from './CrossPlatformFileChangeSource'; import { type DataCache } from './DataCache'; import { LocalFileSystemProvider } from './LocalFileSystemProvider'; import { type NotificationManager } from './NotificationManager'; @@ -52,6 +55,10 @@ const WATCHER_RETRY_MS = 2000; const TEAMS_POLL_INTERVAL_MS = 1000; /** Poll interval for task files, which can be much larger than team metadata/inboxes */ const TASKS_POLL_INTERVAL_MS = 3000; +/** Bound each projects polling slice so fallback/SSH mode cannot rescan huge histories every tick. */ +const PROJECTS_POLL_PROJECT_SLICE_BUDGET = 64; +/** Soft cap: a single large project can exceed this, but broad trees are split across ticks. */ +const PROJECTS_POLL_FILE_SOFT_BUDGET = 1024; /** Interval for periodic catch-up scan to detect missed fs.watch events */ const CATCH_UP_INTERVAL_MS = 30_000; /** Only catch-up scan files modified within this window */ @@ -106,6 +113,10 @@ export class FileWatcher extends EventEmitter { private catchUpCursor = 0; /** Consecutive catch-up stat timeouts per file. */ private catchUpStatFailures = new Map(); + /** Cursor for chunked project polling snapshots. */ + private projectsPollCursor = 0; + /** Whether the current project polling cycle has already been split across ticks. */ + private projectsPollCycleChunked = false; /** Polling interval for projects fallback and SSH mode. */ private static readonly SSH_POLL_INTERVAL_MS = 3000; /** Files currently being processed (concurrency guard) */ @@ -302,6 +313,8 @@ export class FileWatcher extends EventEmitter { this.lastProcessedSize.clear(); this.activeSessionFiles.clear(); this.catchUpStatFailures.clear(); + this.projectsPollCursor = 0; + this.projectsPollCycleChunked = false; this.processingInProgress.clear(); this.pendingReprocess.clear(); @@ -683,15 +696,30 @@ export class FileWatcher extends EventEmitter { this.changeSources.projects.startPolling(); } - private async collectProjectsPollSnapshot(): Promise> { + private async collectProjectsPollSnapshot(): Promise { const snapshot = new Map(); - const projectDirs = await this.readProviderSnapshotDir(this.projectsPath); + const projectDirs = (await this.readProviderSnapshotDir(this.projectsPath)) + .filter((entry) => entry.isDirectory()) + .sort((left, right) => left.name.localeCompare(right.name)); - for (const projectDir of projectDirs) { - if (!projectDir.isDirectory()) { - continue; - } + if (projectDirs.length === 0) { + this.projectsPollCursor = 0; + this.projectsPollCycleChunked = false; + return { files: snapshot, cycleComplete: true, deleteSafe: true }; + } + if (this.projectsPollCursor >= projectDirs.length) { + this.projectsPollCursor = 0; + this.projectsPollCycleChunked = false; + } + + let index = this.projectsPollCursor; + let visitedProjects = 0; + let collectedFiles = 0; + + while (visitedProjects < projectDirs.length) { + const projectDir = projectDirs[index]; + const sizeBefore = snapshot.size; const projectPath = path.join(this.projectsPath, projectDir.name); const entries = await this.readProviderSnapshotDir(projectPath); for (const entry of entries) { @@ -726,9 +754,31 @@ export class FileWatcher extends EventEmitter { ); } } + + collectedFiles += snapshot.size - sizeBefore; + visitedProjects += 1; + index = (index + 1) % projectDirs.length; + + if (index === 0) { + const deleteSafe = !this.projectsPollCycleChunked; + this.projectsPollCursor = 0; + this.projectsPollCycleChunked = false; + return { files: snapshot, cycleComplete: true, deleteSafe }; + } + + if ( + visitedProjects >= PROJECTS_POLL_PROJECT_SLICE_BUDGET || + collectedFiles >= PROJECTS_POLL_FILE_SOFT_BUDGET + ) { + this.projectsPollCursor = index; + this.projectsPollCycleChunked = true; + return { files: snapshot, cycleComplete: false }; + } } - return snapshot; + this.projectsPollCursor = 0; + this.projectsPollCycleChunked = false; + return { files: snapshot, cycleComplete: true, deleteSafe: true }; } private async collectTodosPollSnapshot(): Promise> { diff --git a/src/main/utils/startupTelemetry.ts b/src/main/utils/startupTelemetry.ts new file mode 100644 index 00000000..d78f6e8b --- /dev/null +++ b/src/main/utils/startupTelemetry.ts @@ -0,0 +1,26 @@ +import type { AppStartupMemorySnapshot } from '@shared/types'; + +export type MemoryUsageReader = () => NodeJS.MemoryUsage; + +export function captureStartupMemorySnapshot( + readMemoryUsage: MemoryUsageReader = () => process.memoryUsage() +): AppStartupMemorySnapshot { + const memory = readMemoryUsage(); + return { + rssBytes: memory.rss, + heapUsedBytes: memory.heapUsed, + heapTotalBytes: memory.heapTotal, + externalBytes: memory.external, + arrayBuffersBytes: memory.arrayBuffers, + }; +} + +export function formatStartupMemorySnapshot(memory: AppStartupMemorySnapshot): string { + return `rss=${formatMiB(memory.rssBytes)} heap=${formatMiB(memory.heapUsedBytes)}/${formatMiB( + memory.heapTotalBytes + )} external=${formatMiB(memory.externalBytes)}`; +} + +function formatMiB(bytes: number): string { + return `${(bytes / 1024 / 1024).toFixed(1)}MiB`; +} diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 52994ede..34839ee4 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -100,6 +100,8 @@ const TASK_LOG_ACTIVITY_PULSE_MS = 3_500; const STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS = 30_000; const STARTUP_PROVIDER_STATUS_MIN_DELAY_MS = 2_000; const STARTUP_PROVIDER_STATUS_MAX_DELAY_MS = 30_000; +const STARTUP_GLOBAL_TASKS_MIN_DELAY_MS = 5_000; +const STARTUP_GLOBAL_TASKS_MAX_DELAY_MS = 30_000; const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet = new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']); export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout'; @@ -216,6 +218,8 @@ export function initializeNotificationListeners(): () => void { let cliStatusTimer: ReturnType | null = null; let runtimeStatusTimer: ReturnType | null = null; let deferredProviderStatusCleanup: (() => void) | null = null; + let deferredGlobalTasksCleanup: (() => void) | null = null; + let disposed = false; useStore.getState().subscribeProvisioningProgress(); cleanupFns.push(() => { useStore.getState().unsubscribeProvisioningProgress(); @@ -286,18 +290,33 @@ export function initializeNotificationListeners(): () => void { runtimeStatusTimer = null; }, STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS); - // Remaining visible startup fetches have no data dependency on each other. + // Keep immediately visible startup data first; global task aggregation can + // scan all team task files, so hydrate it after first paint/idle. await Promise.all([ - useStore.getState().fetchAllTasks(), useStore.getState().fetchTeams(), useStore.getState().fetchNotifications(), useStore.getState().fetchSchedules(), ]); + if (disposed) { + return; + } + deferredGlobalTasksCleanup = scheduleStartupIdleTask( + () => { + deferredGlobalTasksCleanup = null; + void useStore.getState().fetchAllTasks(); + }, + { + minDelayMs: STARTUP_GLOBAL_TASKS_MIN_DELAY_MS, + maxDelayMs: STARTUP_GLOBAL_TASKS_MAX_DELAY_MS, + } + ); })(); cleanupFns.push(() => { + disposed = true; if (cliStatusTimer) clearTimeout(cliStatusTimer); if (runtimeStatusTimer) clearTimeout(runtimeStatusTimer); if (deferredProviderStatusCleanup) deferredProviderStatusCleanup(); + if (deferredGlobalTasksCleanup) deferredGlobalTasksCleanup(); }); // TODO(task-change-presence): re-enable this only after the board uses a bounded // batch/priority presence pipeline. The old one-task-per-tick poll was accurate diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index e1bf644b..babea1bf 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -337,6 +337,7 @@ export interface AppStartupStatus { startedAt: number; updatedAt: number; steps?: AppStartupStep[]; + memory?: AppStartupMemorySnapshot; } export interface AppStartupStep { @@ -346,6 +347,8 @@ export interface AppStartupStep { updatedAt: number; finishedAt?: number; durationMs?: number; + memoryAtStart?: AppStartupMemorySnapshot; + memoryAtEnd?: AppStartupMemorySnapshot; } export interface AppStartupAPI { @@ -353,6 +356,14 @@ export interface AppStartupAPI { onProgress: (callback: (status: AppStartupStatus) => void) => () => void; } +export interface AppStartupMemorySnapshot { + rssBytes: number; + heapUsedBytes: number; + heapTotalBytes: number; + externalBytes: number; + arrayBuffersBytes?: number; +} + // ============================================================================= // Context API // ============================================================================= diff --git a/test/main/services/infrastructure/CrossPlatformFileChangeSource.test.ts b/test/main/services/infrastructure/CrossPlatformFileChangeSource.test.ts index 0ea2b0d1..205e7470 100644 --- a/test/main/services/infrastructure/CrossPlatformFileChangeSource.test.ts +++ b/test/main/services/infrastructure/CrossPlatformFileChangeSource.test.ts @@ -595,4 +595,126 @@ describe('CrossPlatformFileChangeSource', () => { source.stop(); active = false; }); + + it('builds a silent startup baseline across incomplete polling cycles', async () => { + let active = true; + const emitted: Array<[string, string]> = []; + const collectPollSnapshot = vi + .fn() + .mockResolvedValueOnce({ + files: new Map([['a.jsonl', '1']]), + cycleComplete: false, + }) + .mockResolvedValueOnce({ + files: new Map([['b.jsonl', '1']]), + cycleComplete: true, + }) + .mockResolvedValueOnce({ + files: new Map([ + ['a.jsonl', '2'], + ['b.jsonl', '1'], + ]), + cycleComplete: true, + }); + const source = new CrossPlatformFileChangeSource({ + name: 'test-source', + pollIntervalMs: 1000, + collectPollSnapshot, + emitPolledChange: (eventType, relativePath) => emitted.push([eventType, relativePath]), + isOwnerActive: () => active, + isWatchLimitError: () => false, + requestRetry: vi.fn(), + }); + + await source.pollOnce(); + expect(source.isPollingPrimed).toBe(false); + await source.pollOnce(); + expect(source.isPollingPrimed).toBe(true); + expect(emitted).toEqual([]); + + await source.pollOnce(); + + expect(emitted).toEqual([['change', 'a.jsonl']]); + source.stop(); + active = false; + }); + + it('does not emit deletes from incomplete polling snapshots', async () => { + let active = true; + const emitted: Array<[string, string]> = []; + const collectPollSnapshot = vi + .fn() + .mockResolvedValueOnce( + new Map([ + ['a.jsonl', '1'], + ['b.jsonl', '1'], + ]) + ) + .mockResolvedValueOnce({ + files: new Map([['a.jsonl', '1']]), + cycleComplete: false, + }) + .mockResolvedValueOnce({ + files: new Map(), + cycleComplete: true, + }); + const source = new CrossPlatformFileChangeSource({ + name: 'test-source', + pollIntervalMs: 1000, + collectPollSnapshot, + emitPolledChange: (eventType, relativePath) => emitted.push([eventType, relativePath]), + isOwnerActive: () => active, + isWatchLimitError: () => false, + requestRetry: vi.fn(), + }); + + await source.pollOnce(); + await source.pollOnce(); + expect(emitted).toEqual([]); + + await source.pollOnce(); + + expect(emitted).toEqual([['rename', 'b.jsonl']]); + source.stop(); + active = false; + }); + + it('suppresses deletes when a completed polling cycle is not delete-safe', async () => { + let active = true; + const emitted: Array<[string, string]> = []; + const collectPollSnapshot = vi + .fn() + .mockResolvedValueOnce( + new Map([ + ['a.jsonl', '1'], + ['b.jsonl', '1'], + ]) + ) + .mockResolvedValueOnce({ + files: new Map([['a.jsonl', '1']]), + cycleComplete: false, + }) + .mockResolvedValueOnce({ + files: new Map(), + cycleComplete: true, + deleteSafe: false, + }); + const source = new CrossPlatformFileChangeSource({ + name: 'test-source', + pollIntervalMs: 1000, + collectPollSnapshot, + emitPolledChange: (eventType, relativePath) => emitted.push([eventType, relativePath]), + isOwnerActive: () => active, + isWatchLimitError: () => false, + requestRetry: vi.fn(), + }); + + await source.pollOnce(); + await source.pollOnce(); + await source.pollOnce(); + + expect(emitted).toEqual([]); + source.stop(); + active = false; + }); }); diff --git a/test/main/services/infrastructure/FileWatcher.test.ts b/test/main/services/infrastructure/FileWatcher.test.ts index 1401e589..15c5d621 100644 --- a/test/main/services/infrastructure/FileWatcher.test.ts +++ b/test/main/services/infrastructure/FileWatcher.test.ts @@ -641,6 +641,70 @@ describe('FileWatcher', () => { watcher.stop(); }); + it('chunks broad project polling baselines and still emits changes after priming', async () => { + const projectsDir = '/virtual/projects'; + const todosDir = '/virtual/todos'; + const projectNames = Array.from({ length: 65 }, (_, index) => + `encoded-project-${String(index).padStart(3, '0')}` + ); + const fileState = new Map(projectNames.map((name) => [name, { size: 10, mtimeMs: 1000 }])); + const fsProvider = { + type: 'local' as const, + exists: vi.fn().mockResolvedValue(true), + readFile: vi.fn().mockResolvedValue(''), + stat: vi.fn().mockResolvedValue({ + size: 10, + mtimeMs: 1000, + birthtimeMs: 1000, + isFile: () => true, + isDirectory: () => false, + }), + readdir: vi.fn(async (dirPath: string) => { + if (dirPath === projectsDir) { + return projectNames.map((name) => createFsDirent(name, 'directory')); + } + const projectName = path.basename(dirPath); + const state = fileState.get(projectName); + if (state) { + return [createFsDirent('session-1.jsonl', 'file', state)]; + } + return []; + }), + createReadStream: vi.fn(() => Readable.from([])), + dispose: vi.fn(), + }; + + const dataCache = new DataCache(50, 10, false); + const watcher = new FileWatcher(dataCache, projectsDir, todosDir, fsProvider); + const events: unknown[] = []; + watcher.on('file-change', (event) => events.push(event)); + + setWatcherActive(watcher); + const projectsSource = getChangeSource(watcher, 'projects'); + + await projectsSource.pollOnce(); + expect(projectsSource.isPollingPrimed).toBe(false); + expect(events).toEqual([]); + + await projectsSource.pollOnce(); + expect(projectsSource.isPollingPrimed).toBe(true); + expect(events).toEqual([]); + + fileState.set(projectNames[0], { size: 12, mtimeMs: 2000 }); + await projectsSource.pollOnce(); + await vi.advanceTimersByTimeAsync(100); + + expect(events).toContainEqual({ + type: 'change', + path: path.join(projectsDir, projectNames[0], 'session-1.jsonl'), + projectId: projectNames[0], + sessionId: 'session-1', + isSubagent: false, + }); + + watcher.stop(); + }); + it('treats SSH not-found subagent directories as empty during project polling', async () => { const projectsDir = '/remote/projects'; const todosDir = '/remote/todos'; diff --git a/test/main/utils/startupTelemetry.test.ts b/test/main/utils/startupTelemetry.test.ts new file mode 100644 index 00000000..4df4bc06 --- /dev/null +++ b/test/main/utils/startupTelemetry.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { + captureStartupMemorySnapshot, + formatStartupMemorySnapshot, +} from '../../../src/main/utils/startupTelemetry'; + +describe('startupTelemetry', () => { + it('captures only stable numeric memory fields', () => { + const snapshot = captureStartupMemorySnapshot(() => ({ + rss: 128 * 1024 * 1024, + heapTotal: 64 * 1024 * 1024, + heapUsed: 32 * 1024 * 1024, + external: 8 * 1024 * 1024, + arrayBuffers: 4 * 1024 * 1024, + })); + + expect(snapshot).toEqual({ + rssBytes: 134217728, + heapUsedBytes: 33554432, + heapTotalBytes: 67108864, + externalBytes: 8388608, + arrayBuffersBytes: 4194304, + }); + }); + + it('formats rss and heap values for startup logs', () => { + expect( + formatStartupMemorySnapshot({ + rssBytes: 128 * 1024 * 1024, + heapUsedBytes: 32 * 1024 * 1024, + heapTotalBytes: 64 * 1024 * 1024, + externalBytes: 8 * 1024 * 1024, + arrayBuffersBytes: 4 * 1024 * 1024, + }) + ).toBe('rss=128.0MiB heap=32.0MiB/64.0MiB external=8.0MiB'); + }); +}); diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 4ce938e3..8bc2710a 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -202,6 +202,38 @@ describe('team change throttling', () => { expect(getRepositoryGroupsSpy).not.toHaveBeenCalled(); }); + it('defers the initial global task fetch until the startup idle window', async () => { + const fetchAllTasksSpy = vi.fn(async () => undefined); + useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never); + + cleanup?.(); + cleanup = initializeNotificationListeners(); + await vi.advanceTimersByTimeAsync(0); + + expect(fetchAllTasksSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(4_999); + expect(fetchAllTasksSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(fetchAllTasksSpy).toHaveBeenCalledTimes(1); + }); + + it('cancels the deferred initial global task fetch during listener cleanup', async () => { + const fetchAllTasksSpy = vi.fn(async () => undefined); + useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never); + + cleanup?.(); + cleanup = initializeNotificationListeners(); + await vi.advanceTimersByTimeAsync(0); + cleanup(); + cleanup = null; + + await vi.advanceTimersByTimeAsync(30_000); + + expect(fetchAllTasksSpy).not.toHaveBeenCalled(); + }); + it('allows next refresh after throttle window passes', async () => { const state = useStore.getState(); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');