From 7c00cb60afd316b17301129db943bf5ac54e3672 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Tue, 2 Jun 2026 19:49:07 +0200 Subject: [PATCH] perf: cut idle CPU from leaked watchers and unconditional polling The main process pegged ~53% CPU continuously while idle. Three causes: - main.js: each subagent live-tail watcher used fs.watchFile, which stat-polls the file once per second per watcher, forever. Teardown via fs.unwatchFile(path) was fragile and watchers could accumulate across a long app session. Switch to event-driven fs.watch + 300ms debounce, and store a per-watcher teardown() closure called from both stop-subagent-watch and the window-closed handler so nothing leaks. Falls back to a 10s poll only if fs.watch fails to attach. - public/app.js: pollActiveSessions ran every 3s unconditionally (IPC + full-sidebar querySelectorAll) even with zero running sessions. Poll adaptively: 3s while sessions run, 30s when idle. In-renderer session starts re-arm the fast cadence immediately; the 30s floor still catches externally-started sessions. The 30s timeago interval now no-ops when nothing is active. - schedule-runner.js: scanSchedules re-read 4KB of every project JSONL every 60s just to extract projectPath. Resolve it from the cache_meta SQLite cache (getAllFolderMeta) instead, falling back to the JSONL read only when a folder is genuinely uncached. Lint clean; existing 119-test suite passes unchanged. --- main.js | 48 ++++++++++++++++++++++++++++++------ public/app.js | 20 ++++++++++++++- schedule-runner.js | 61 +++++++++++++++++++++++++++++++++++----------- 3 files changed, 107 insertions(+), 22 deletions(-) diff --git a/main.js b/main.js index 6f9c062a..9bae4d77 100644 --- a/main.js +++ b/main.js @@ -91,7 +91,7 @@ const MAX_BUFFER_SIZE = 256 * 1024; const activeSessions = new Map(); let mainWindow = null; -// Subagent live-tail watchers (watchId → { filePath, parentSessionId, agentId }) +// Subagent live-tail watchers (watchId → { filePath, parentSessionId, agentId, teardown }) const subagentWatchers = new Map(); let subagentWatcherSeq = 0; @@ -221,9 +221,10 @@ function createWindow() { } activeSessions.delete(id); } - // Release all subagent file watchers + // Release all subagent file watchers (closes fs.watch handles + clears any + // debounce timers / polling fallbacks via the stored teardown closure) for (const [, entry] of subagentWatchers) { - try { fs.unwatchFile(entry.filePath); } catch {} + try { entry.teardown(); } catch {} } subagentWatchers.clear(); mainWindow = null; @@ -1298,10 +1299,43 @@ ipcMain.handle('start-subagent-watch', (_event, parentSessionId, agentId) => { } catch {} } - // fs.watchFile gives reliable polling on Linux where inotify can be unreliable for JSONL appends - fs.watchFile(filePath, { interval: 1000, persistent: false }, readNewEntries); + // Coalesce rapid JSONL appends into a single incremental read. Mirrors the + // debounce used by the projects watcher (see startProjectsWatcher) instead of + // polling stat() once per second per watcher, which pegged the main process at + // idle when watchers accumulated. + let debounceTimer = null; + function scheduleRead() { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + readNewEntries(); + }, 300); + } + + let watcher = null; + let pollInterval = null; + try { + watcher = fs.watch(filePath, { persistent: false }, (eventType) => { + if (eventType === 'rename') return; // file replaced/removed — ignore + scheduleRead(); + }); + watcher.on('error', (err) => { + log.warn(`[subagent-watch] fs.watch error watchId=${watchId}: ${err.message}`); + }); + } catch (err) { + // Robustness fallback only when fs.watch can't attach: poll on a long + // interval (10s) so a failed inotify registration never busy-stats the file. + log.warn(`[subagent-watch] fs.watch failed watchId=${watchId}, polling fallback: ${err.message}`); + pollInterval = setInterval(readNewEntries, 10000); + } + + function teardown() { + if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; } + if (watcher) { try { watcher.close(); } catch {} watcher = null; } + if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } + } - subagentWatchers.set(watchId, { filePath, parentSessionId, agentId }); + subagentWatchers.set(watchId, { filePath, parentSessionId, agentId, teardown }); log.info(`[subagent-watch] start watchId=${watchId} parent=${parentSessionId} agentId=${agentId}`); return { watchId }; }); @@ -1309,7 +1343,7 @@ ipcMain.handle('start-subagent-watch', (_event, parentSessionId, agentId) => { ipcMain.handle('stop-subagent-watch', (_event, watchId) => { const entry = subagentWatchers.get(watchId); if (!entry) return { ok: false }; - fs.unwatchFile(entry.filePath); + entry.teardown(); subagentWatchers.delete(watchId); log.info(`[subagent-watch] stop watchId=${watchId}`); return { ok: true }; diff --git a/public/app.js b/public/app.js index 47020991..b8aee639 100644 --- a/public/app.js +++ b/public/app.js @@ -594,6 +594,22 @@ terminalStopBtn.addEventListener('click', () => { // --- Poll for active PTY sessions --- +// Adaptive cadence: poll fast (3s) only while PTYs are running; when idle, back +// off to 30s. Every renderer path that starts a session (launchNewSession, +// openSession, launchTerminalSession, onSessionDetected/Forked) calls +// pollActiveSessions() explicitly, which re-arms the fast cadence immediately. +// The 30s idle floor still catches sessions started outside the renderer +// (scheduler-spawned PTYs, other windows) within at most 30s. +const POLL_FAST_MS = 3000; +const POLL_IDLE_MS = 30000; +let pollTimer = null; + +function scheduleActiveSessionsPoll() { + if (pollTimer) clearTimeout(pollTimer); + const delay = activePtyIds.size > 0 ? POLL_FAST_MS : POLL_IDLE_MS; + pollTimer = setTimeout(pollActiveSessions, delay); +} + async function pollActiveSessions() { try { const ids = await window.api.getActiveSessions(); @@ -601,6 +617,7 @@ async function pollActiveSessions() { updateRunningIndicators(); updateTerminalHeader(); } catch {} + scheduleActiveSessionsPoll(); } function updateRunningIndicators() { @@ -655,10 +672,11 @@ function updatePtyTitle() { terminalHeaderPtyTitle.style.display = title ? '' : 'none'; } -setInterval(pollActiveSessions, 3000); +scheduleActiveSessionsPoll(); // Refresh sidebar timeago labels every 30s so "just now" ticks forward setInterval(() => { + if (lastActivityTime.size === 0) return; for (const [sessionId, time] of lastActivityTime) { const item = document.getElementById('si-' + sessionId); if (!item) continue; diff --git a/schedule-runner.js b/schedule-runner.js index b8a51efc..873e31c1 100644 --- a/schedule-runner.js +++ b/schedule-runner.js @@ -74,6 +74,44 @@ function cronMatches(cronExpr, now) { ); } +/** + * Resolve a project folder name to its project path from the SQLite cache. + * Returns a Map, or an empty Map if the cache is + * unavailable (e.g. in tests that don't load the native DB binding). + */ +function loadFolderMetaMap() { + try { + // Lazy require so requiring schedule-runner.js never forces the native + // better-sqlite3 binding to load (keeps the module test-friendly). + const { getAllFolderMeta } = require('./db'); + const meta = getAllFolderMeta(); + const map = new Map(); + for (const [folder, row] of meta) { + if (row && row.projectPath) map.set(folder, row.projectPath); + } + return map; + } catch { + return new Map(); + } +} + +/** Read a project folder's first JSONL just enough to extract its cwd. */ +function readProjectPathFromJsonl(folderPath) { + try { + const jsonlFiles = fs.readdirSync(folderPath).filter(f => f.endsWith('.jsonl')); + for (const jf of jsonlFiles) { + const head = fs.readFileSync(path.join(folderPath, jf), 'utf8').slice(0, 4000); + for (const line of head.split('\n').filter(Boolean)) { + try { + const entry = JSON.parse(line); + if (entry.cwd) return entry.cwd; + } catch {} + } + } + } catch {} + return null; +} + /** Scan all projects for schedule-*.md files and return parsed schedule objects. */ function scanSchedules(log) { const schedules = []; @@ -82,22 +120,17 @@ function scanSchedules(log) { const folders = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true }) .filter(d => d.isDirectory()); + // Prefer the cached folder→projectPath mapping; only read JSONLs for + // folders genuinely missing from the cache. This avoids re-reading 4KB of + // every JSONL of every project on each 60s tick. + const folderMeta = loadFolderMetaMap(); + for (const folder of folders) { const folderPath = path.join(PROJECTS_DIR, folder.name); - let projectPath = null; - try { - const jsonlFiles = fs.readdirSync(folderPath).filter(f => f.endsWith('.jsonl')); - for (const jf of jsonlFiles) { - const head = fs.readFileSync(path.join(folderPath, jf), 'utf8').slice(0, 4000); - for (const line of head.split('\n').filter(Boolean)) { - try { - const entry = JSON.parse(line); - if (entry.cwd) { projectPath = entry.cwd; break; } - } catch {} - } - if (projectPath) break; - } - } catch {} + let projectPath = folderMeta.get(folder.name) || null; + if (!projectPath) { + projectPath = readProjectPathFromJsonl(folderPath); + } if (!projectPath) continue; const commandsDir = path.join(projectPath, '.claude', 'commands');