From 83698e530f5c67beb5f9d4e6e8f6c2973f6473cf Mon Sep 17 00:00:00 2001 From: elhoim Date: Wed, 30 Sep 2026 17:21:05 +0000 Subject: [PATCH] perf(render): skip unused skills read, cache account email by mtime Two per-render file reads did work that the output does not need: - getSkillsMetrics() existsSync+read+parsed the per-session skills log on every render even with no `skills` widget configured. It is now gated on a skills widget being present, like the other widget-specific inputs (speed, compaction, session-name). Saving is noise-level in practice (the skills hook is removed together with the widget, so the file is usually absent), but the read is pure waste. - The claude-account-email widget read and JSON.parsed the whole .claude.json on every render. That file also holds Claude Code's per-project history and grows to megabytes. The extracted email (or its absence) is now cached in ~/.cache/ccstatusline/claude-account-email.json (mode 0600) keyed on path + size + mtimeMs, so a hit costs one stat and a ~150-byte read. Stat precedes the read, so a concurrent rewrite can only cause an extra re-read, never a stale value; parse failures are not cached. Behaviour is unchanged (output byte-identical for present, missing, non-string, empty, corrupt and absent cases, miss and hit). Measured (bench of fork/exec renders, 30 interleaved passes, host load ~50 on 6 cores; CPU user+sys median / p90, ms), config = default + email widget, no transcript: .claude.json 1.1 MB: 1304 / 1443 -> 1163 / 1284 (-10.8%) .claude.json 5.6 MB: 1414 / 1538 -> 1109 / 1336 (-21.6%) In-process: 1 MB read+parse 15.0 ms -> 1.3 ms on a hit; 5.6 MB 76.7 ms -> 1.2 ms; a miss costs the same as before plus a small cache write. At ~100 KB the difference is within noise. Co-Authored-By: Claude Opus 5.5 --- src/ccstatusline.ts | 4 +- .../__tests__/claude-account-email.test.ts | 143 ++++++++++++++++++ src/utils/claude-account-email.ts | 122 +++++++++++++++ src/widgets/ClaudeAccountEmail.ts | 21 +-- .../__tests__/ClaudeAccountEmail.test.ts | 2 + 5 files changed, 275 insertions(+), 17 deletions(-) create mode 100644 src/utils/__tests__/claude-account-email.test.ts create mode 100644 src/utils/claude-account-email.ts diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 1da963f8f..7dff8c37a 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -109,6 +109,7 @@ async function renderMultipleLines(data: StatusJSON) { const hasCompactionWidget = lines.some(line => line.some(item => item.type === 'compaction-counter')); const hasThinkingEffortWidget = lines.some(line => line.some(item => item.type === 'thinking-effort')); const hasSessionNameWidget = lines.some(line => line.some(item => item.type === 'session-name')); + const hasSkillsWidget = lines.some(line => line.some(item => item.type === 'skills')); const needsTranscriptThinkingEffort = hasThinkingEffortWidget && (!data.effort || !('level' in data.effort)); const requestedSpeedWindows = new Set(); @@ -143,7 +144,8 @@ async function renderMultipleLines(data: StatusJSON) { const windowedSpeedMetrics = transcriptAnalysis?.speedMetricsCollection?.windowed ?? null; let skillsMetrics: SkillsMetrics | null = null; - if (data.session_id) { + // Only the skills widget reads these; skip the per-session log read otherwise. + if (hasSkillsWidget && data.session_id) { skillsMetrics = getSkillsMetrics(data.session_id); } diff --git a/src/utils/__tests__/claude-account-email.test.ts b/src/utils/__tests__/claude-account-email.test.ts new file mode 100644 index 000000000..4527b73d4 --- /dev/null +++ b/src/utils/__tests__/claude-account-email.test.ts @@ -0,0 +1,143 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import { + getClaudeAccountEmail, + type AccountEmailDeps +} from '../claude-account-email'; + +let tempDir: string; +let claudeJsonPath: string; +let cachePath: string; +let deps: AccountEmailDeps; +let readSpy: ReturnType; + +function writeClaudeJson(content: string, mtimeSeconds?: number): void { + fs.writeFileSync(claudeJsonPath, content, 'utf-8'); + if (mtimeSeconds !== undefined) { + fs.utimesSync(claudeJsonPath, mtimeSeconds, mtimeSeconds); + } +} + +function claudeJsonReads(): number { + return readSpy.mock.calls.filter(call => call[0] === claudeJsonPath).length; +} + +describe('getClaudeAccountEmail', () => { + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-account-email-')); + claudeJsonPath = path.join(tempDir, '.claude.json'); + cachePath = path.join(tempDir, 'cache', 'claude-account-email.json'); + readSpy = vi.fn((p: string) => fs.readFileSync(p, 'utf-8')); + deps = { + statSync: (p: string) => fs.statSync(p), + readFileSync: readSpy as unknown as (p: string) => string, + writeFileSync: (p: string, data: string) => { fs.writeFileSync(p, data, 'utf-8'); }, + renameSync: (from: string, to: string) => { fs.renameSync(from, to); }, + mkdirSync: (p: string) => { fs.mkdirSync(p, { recursive: true }); }, + getClaudeJsonPath: () => claudeJsonPath, + getCachePath: () => cachePath + }; + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('returns null without caching when .claude.json is missing', () => { + expect(getClaudeAccountEmail(deps)).toBeNull(); + expect(fs.existsSync(cachePath)).toBe(false); + }); + + it('parses .claude.json once and serves repeat calls from the cache', () => { + writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 'user@example.com' } })); + + expect(getClaudeAccountEmail(deps)).toBe('user@example.com'); + expect(getClaudeAccountEmail(deps)).toBe('user@example.com'); + expect(claudeJsonReads()).toBe(1); + }); + + it('caches a missing email as a hit', () => { + writeClaudeJson(JSON.stringify({ projects: {} })); + + expect(getClaudeAccountEmail(deps)).toBeNull(); + expect(getClaudeAccountEmail(deps)).toBeNull(); + expect(claudeJsonReads()).toBe(1); + }); + + it('treats non-string and empty emails as missing', () => { + writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 12345 } }), 1000); + expect(getClaudeAccountEmail(deps)).toBeNull(); + + writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: '' } }), 2000); + expect(getClaudeAccountEmail(deps)).toBeNull(); + }); + + it('re-reads when the mtime changes', () => { + writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 'a@example.com' } }), 1000); + expect(getClaudeAccountEmail(deps)).toBe('a@example.com'); + + // Same size, different mtime. + writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 'b@example.com' } }), 2000); + expect(getClaudeAccountEmail(deps)).toBe('b@example.com'); + expect(claudeJsonReads()).toBe(2); + }); + + it('re-reads when the size changes', () => { + writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 'a@example.com' } }), 1000); + expect(getClaudeAccountEmail(deps)).toBe('a@example.com'); + + // Same mtime, different size. + writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 'longer@example.com' } }), 1000); + expect(getClaudeAccountEmail(deps)).toBe('longer@example.com'); + expect(claudeJsonReads()).toBe(2); + }); + + it('re-reads when the cache was written for a different .claude.json path', () => { + writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 'a@example.com' } }), 1000); + expect(getClaudeAccountEmail(deps)).toBe('a@example.com'); + + const otherPath = path.join(tempDir, 'other.json'); + fs.copyFileSync(claudeJsonPath, otherPath); + fs.utimesSync(otherPath, 1000, 1000); + fs.writeFileSync(otherPath, JSON.stringify({ oauthAccount: { emailAddress: 'b@example.com' } }), 'utf-8'); + fs.utimesSync(otherPath, 1000, 1000); + claudeJsonPath = otherPath; + + expect(getClaudeAccountEmail(deps)).toBe('b@example.com'); + }); + + it('treats a corrupt cache file as a miss', () => { + writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 'user@example.com' } })); + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + fs.writeFileSync(cachePath, '{not json', 'utf-8'); + + expect(getClaudeAccountEmail(deps)).toBe('user@example.com'); + expect(getClaudeAccountEmail(deps)).toBe('user@example.com'); + expect(claudeJsonReads()).toBe(1); + }); + + it('does not cache a parse failure, so a later valid file is picked up', () => { + writeClaudeJson('{not json', 1000); + expect(getClaudeAccountEmail(deps)).toBeNull(); + expect(getClaudeAccountEmail(deps)).toBeNull(); + expect(claudeJsonReads()).toBe(2); + expect(fs.existsSync(cachePath)).toBe(false); + }); + + it('still returns the email when the cache cannot be written', () => { + writeClaudeJson(JSON.stringify({ oauthAccount: { emailAddress: 'user@example.com' } })); + deps.writeFileSync = () => { throw new Error('EROFS'); }; + + expect(getClaudeAccountEmail(deps)).toBe('user@example.com'); + }); +}); diff --git a/src/utils/claude-account-email.ts b/src/utils/claude-account-email.ts new file mode 100644 index 000000000..fa52fb9ac --- /dev/null +++ b/src/utils/claude-account-email.ts @@ -0,0 +1,122 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { getClaudeJsonPath } from './claude-settings'; + +const CACHE_SCHEMA_VERSION = 1 as const; + +interface ClaudeJson { oauthAccount?: { emailAddress?: string } } + +interface AccountEmailCacheEntry { + version: typeof CACHE_SCHEMA_VERSION; + path: string; + size: number; + mtimeMs: number; + email: string | null; +} + +export interface AccountEmailDeps { + statSync: (path: string) => { size: number; mtimeMs: number }; + readFileSync: (path: string) => string; + writeFileSync: (path: string, data: string) => void; + renameSync: (from: string, to: string) => void; + mkdirSync: (path: string) => void; + getClaudeJsonPath: () => string; + getCachePath: () => string; +} + +const defaultDeps: AccountEmailDeps = { + statSync: (p: string) => fs.statSync(p), + readFileSync: (p: string) => fs.readFileSync(p, 'utf-8'), + writeFileSync: (p: string, data: string) => { fs.writeFileSync(p, data, { encoding: 'utf-8', mode: 0o600 }); }, + renameSync: (from: string, to: string) => { fs.renameSync(from, to); }, + mkdirSync: (p: string) => { fs.mkdirSync(p, { recursive: true }); }, + getClaudeJsonPath, + // Resolved per call (not at import) so a changed/mocked home is honoured. + getCachePath: () => path.join(os.homedir(), '.cache', 'ccstatusline', 'claude-account-email.json') +}; + +function readCacheEntry(deps: AccountEmailDeps): AccountEmailCacheEntry | null { + try { + const parsed = JSON.parse(deps.readFileSync(deps.getCachePath())) as unknown; + if (typeof parsed !== 'object' || parsed === null) { + return null; + } + + const entry = parsed as Record; + if (entry.version !== CACHE_SCHEMA_VERSION + || typeof entry.path !== 'string' + || typeof entry.size !== 'number' + || typeof entry.mtimeMs !== 'number' + || (typeof entry.email !== 'string' && entry.email !== null)) { + return null; + } + + return entry as unknown as AccountEmailCacheEntry; + } catch { + // Missing or corrupt cache is a miss, never a failure. + return null; + } +} + +function writeCacheEntry(entry: AccountEmailCacheEntry, deps: AccountEmailDeps): void { + try { + const cachePath = deps.getCachePath(); + deps.mkdirSync(path.dirname(cachePath)); + const tempPath = `${cachePath}.${process.pid}.tmp`; + deps.writeFileSync(tempPath, JSON.stringify(entry)); + deps.renameSync(tempPath, cachePath); + } catch { + // Best-effort cache; the statusline must render regardless. + } +} + +/** + * Returns the logged-in account email from `.claude.json`, or null when it is + * missing, empty, not a string, or the file is absent/unparseable. + * + * `.claude.json` also holds Claude Code's per-project history, so it can grow + * to megabytes and parsing it on every render is the dominant cost of the + * widget. The extracted email is therefore cached keyed on the file's + * path + size + mtime, and the file is only re-read when one of those changes. + * Stat happens before the read, so a write that lands in between leaves a key + * older than the content and simply causes one extra re-read next time. + * Parse failures are not cached, so they are retried on every render as before. + */ +export function getClaudeAccountEmail(deps: AccountEmailDeps = defaultDeps): string | null { + const claudeJsonPath = deps.getClaudeJsonPath(); + + let stat: { size: number; mtimeMs: number }; + try { + stat = deps.statSync(claudeJsonPath); + } catch { + return null; + } + + const cached = readCacheEntry(deps); + if (cached?.path === claudeJsonPath + && cached.size === stat.size + && cached.mtimeMs === stat.mtimeMs) { + return cached.email; + } + + let email: string | null; + try { + const data = JSON.parse(deps.readFileSync(claudeJsonPath)) as ClaudeJson; + const value = data.oauthAccount?.emailAddress; + email = typeof value === 'string' && value.length > 0 ? value : null; + } catch { + return null; + } + + writeCacheEntry({ + version: CACHE_SCHEMA_VERSION, + path: claudeJsonPath, + size: stat.size, + mtimeMs: stat.mtimeMs, + email + }, deps); + + return email; +} diff --git a/src/widgets/ClaudeAccountEmail.ts b/src/widgets/ClaudeAccountEmail.ts index aff428027..1aca7dc3d 100644 --- a/src/widgets/ClaudeAccountEmail.ts +++ b/src/widgets/ClaudeAccountEmail.ts @@ -1,5 +1,3 @@ -import * as fs from 'fs'; - import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { @@ -7,9 +5,7 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; -import { getClaudeJsonPath } from '../utils/claude-settings'; - -interface ClaudeJson { oauthAccount?: { emailAddress?: string } } +import { getClaudeAccountEmail } from '../utils/claude-account-email'; export class ClaudeAccountEmailWidget implements Widget { getDefaultColor(): string { return 'blue'; } @@ -25,19 +21,12 @@ export class ClaudeAccountEmailWidget implements Widget { return item.rawValue ? 'you@example.com' : 'Account: you@example.com'; } - try { - const content = fs.readFileSync(getClaudeJsonPath(), 'utf-8'); - const data = JSON.parse(content) as ClaudeJson; - const email = data.oauthAccount?.emailAddress; - - if (typeof email !== 'string' || email.length === 0) { - return null; - } - - return item.rawValue ? email : `Account: ${email}`; - } catch { + const email = getClaudeAccountEmail(); + if (email === null) { return null; } + + return item.rawValue ? email : `Account: ${email}`; } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/__tests__/ClaudeAccountEmail.test.ts b/src/widgets/__tests__/ClaudeAccountEmail.test.ts index 4013a6c0a..9952b79fb 100644 --- a/src/widgets/__tests__/ClaudeAccountEmail.test.ts +++ b/src/widgets/__tests__/ClaudeAccountEmail.test.ts @@ -46,6 +46,8 @@ describe('ClaudeAccountEmailWidget', () => { beforeEach(() => { vi.restoreAllMocks(); tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-claude-account-email-')); + // Keep the widget's email cache out of the real ~/.cache. + process.env.HOME = tempHomeDir; }); afterEach(() => {