From 08b028096e1c0936a61dc9ad16f35fd598b5e693 Mon Sep 17 00:00:00 2001 From: elhoim Date: Mon, 13 Jul 2026 09:08:16 +0000 Subject: [PATCH 1/8] perf: add zero-subprocess terminal width probe on Linux Read ancestry from /proc//stat, locate the pty by readlinking /proc//fd/{0,1,2}, and get columns via TIOCGWINSZ (tty.WriteStream) instead of spawning sh+ps / sh+stty+awk / sh+tput. Returns null off Linux so the portable walk remains the fallback for macOS/BSD. Verified against a real pty: probeWidthNative() returns 209, matching 'stty -F /dev/pts/7 size' (88 209), with zero spawns. Co-Authored-By: Claude Opus 4.8 --- src/utils/__tests__/terminal-native.test.ts | 107 ++++++++++++++++ src/utils/terminal-native.ts | 129 ++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 src/utils/__tests__/terminal-native.test.ts create mode 100644 src/utils/terminal-native.ts diff --git a/src/utils/__tests__/terminal-native.test.ts b/src/utils/__tests__/terminal-native.test.ts new file mode 100644 index 00000000..5d0fe7d0 --- /dev/null +++ b/src/utils/__tests__/terminal-native.test.ts @@ -0,0 +1,107 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { NativeProbeDeps } from '../terminal-native'; +import { + parsePpidFromStat, + probeWidthNative +} from '../terminal-native'; + +// A /proc//stat line whose comm field contains spaces AND a close-paren. +// Naive `split(' ')[3]` gets this wrong; fields must be read after the LAST ')'. +const TRICKY_STAT = '4242 (my ) weird proc) S 1234 4242 4242 0 -1 4194304 100 0 0 0 5 3 0 0 20 0 1 0 999 0 0'; + +function makeDeps(overrides: Partial = {}): NativeProbeDeps { + return { + platform: 'linux', + readFileSync: () => { throw new Error('unexpected readFileSync'); }, + readlinkSync: () => { throw new Error('unexpected readlinkSync'); }, + openSync: () => 7, + closeSync: () => undefined, + isatty: () => true, + getColumns: () => 209, + ...overrides + }; +} + +describe('parsePpidFromStat', () => { + it('parses the ppid when comm contains spaces and parens', () => { + expect(parsePpidFromStat(TRICKY_STAT)).toBe(1234); + }); + + it('returns null on garbage', () => { + expect(parsePpidFromStat('not a stat line')).toBeNull(); + }); +}); + +describe('probeWidthNative', () => { + it('returns null on non-linux platforms', () => { + expect(probeWidthNative(makeDeps({ platform: 'darwin' }))).toBeNull(); + }); + + it('walks ancestors and returns the width of the first tty found', () => { + const deps = makeDeps({ + // self -> 4242 -> 1234. Only 1234 owns a pty. + readFileSync: (p: string) => { + if (p === `/proc/${process.pid}/stat`) { + return '1 (node) S 4242 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1 0 0'; + } + + if (p === '/proc/4242/stat') { + return TRICKY_STAT; + } + + throw new Error(`no such stat: ${p}`); + }, + readlinkSync: (p: string) => { + if (p === '/proc/1234/fd/0') { + return '/dev/pts/7'; + } + + throw new Error(`not a tty fd: ${p}`); + }, + getColumns: () => 209 + }); + + expect(probeWidthNative(deps)).toBe(209); + }); + + it('returns null when no ancestor owns a tty', () => { + const deps = makeDeps({ + readFileSync: () => '1 (node) S 0 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1 0 0', + readlinkSync: () => { throw new Error('ENOENT'); } + }); + + expect(probeWidthNative(deps)).toBeNull(); + }); + + it('returns null (and does not throw) when the device is not a tty', () => { + const deps = makeDeps({ + readFileSync: (p: string) => (p === `/proc/${process.pid}/stat` + ? '1 (node) S 1234 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1 0 0' + : (() => { throw new Error('stop'); })()), + readlinkSync: () => '/dev/pts/7', + isatty: () => false + }); + + expect(probeWidthNative(deps)).toBeNull(); + }); + + it('closes the fd even when getColumns throws', () => { + const closed: number[] = []; + const deps = makeDeps({ + readFileSync: (p: string) => (p === `/proc/${process.pid}/stat` + ? '1 (node) S 1234 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1 0 0' + : (() => { throw new Error('stop'); })()), + readlinkSync: () => '/dev/pts/7', + closeSync: (fd: number) => { closed.push(fd); }, + getColumns: () => { throw new Error('ioctl failed'); } + }); + + expect(probeWidthNative(deps)).toBeNull(); + expect(closed).toEqual([7]); + }); +}); diff --git a/src/utils/terminal-native.ts b/src/utils/terminal-native.ts new file mode 100644 index 00000000..8f15e88f --- /dev/null +++ b/src/utils/terminal-native.ts @@ -0,0 +1,129 @@ +import * as fs from 'fs'; +import * as tty from 'tty'; + +const MAX_ANCESTOR_DEPTH = 8; +const STDIO_FDS = [0, 1, 2] as const; + +export interface NativeProbeDeps { + readFileSync: (path: string) => string; + readlinkSync: (path: string) => string; + openSync: (path: string, flags: number) => number; + closeSync: (fd: number) => void; + isatty: (fd: number) => boolean; + getColumns: (fd: number) => number | null; + platform: string; +} + +const defaultDeps: NativeProbeDeps = { + readFileSync: (path: string) => fs.readFileSync(path, 'utf-8'), + readlinkSync: (path: string) => fs.readlinkSync(path, 'utf-8'), + openSync: (path: string, flags: number) => fs.openSync(path, flags), + closeSync: (fd: number) => { fs.closeSync(fd); }, + isatty: (fd: number) => tty.isatty(fd), + getColumns: (fd: number) => { + // tty.WriteStream reads the window size via TIOCGWINSZ. No subprocess. + const stream = new tty.WriteStream(fd); + const columns = stream.columns; + return typeof columns === 'number' && columns > 0 ? columns : null; + }, + platform: process.platform +}; + +/** + * Parse the ppid (field 4) out of a /proc//stat line. + * The comm field (2) is wrapped in parens and may itself contain spaces and + * parens, so fields must be read after the LAST ')'. + */ +export function parsePpidFromStat(stat: string): number | null { + const commEnd = stat.lastIndexOf(')'); + if (commEnd === -1) { + return null; + } + + // After "(comm)" the remaining fields are: state, ppid, ... + const fields = stat.slice(commEnd + 1).trim().split(/\s+/); + const ppid = parseInt(fields[1] ?? '', 10); + if (isNaN(ppid) || ppid <= 0) { + return null; + } + + return ppid; +} + +function findTTYDevice(pid: number, deps: NativeProbeDeps): string | null { + for (const fd of STDIO_FDS) { + try { + const target = deps.readlinkSync(`/proc/${pid}/fd/${fd}`); + if (target.startsWith('/dev/pts/') || target.startsWith('/dev/tty')) { + return target; + } + } catch { + // fd missing or not readable; try the next one + } + } + + return null; +} + +function widthOfDevice(device: string, deps: NativeProbeDeps): number | null { + let fd: number | null = null; + try { + // O_NOCTTY: never adopt this device as our controlling terminal. + fd = deps.openSync(device, fs.constants.O_RDONLY | fs.constants.O_NOCTTY); + if (!deps.isatty(fd)) { + return null; + } + + return deps.getColumns(fd); + } catch { + return null; + } finally { + if (fd !== null) { + try { + deps.closeSync(fd); + } catch { + // best-effort + } + } + } +} + +/** + * Probe terminal width with zero subprocesses, using /proc and TIOCGWINSZ. + * Linux only; returns null anywhere else so the caller falls back to the + * portable ps/stty/tput path. + */ +export function probeWidthNative(deps: NativeProbeDeps = defaultDeps): number | null { + if (deps.platform !== 'linux') { + return null; + } + + let pid = process.pid; + for (let depth = 0; depth < MAX_ANCESTOR_DEPTH; depth += 1) { + let stat: string; + try { + stat = deps.readFileSync(`/proc/${pid}/stat`); + } catch { + return null; + } + + const parentPid = parsePpidFromStat(stat); + if (parentPid === null || parentPid <= 1) { + return null; + } + + pid = parentPid; + + const device = findTTYDevice(pid, deps); + if (device === null) { + continue; + } + + const width = widthOfDevice(device, deps); + if (width !== null) { + return width; + } + } + + return null; +} From c44a7295ad316aecd87cacd79cb5358077bb19a6 Mon Sep 17 00:00:00 2001 From: elhoim Date: Mon, 13 Jul 2026 09:10:33 +0000 Subject: [PATCH 2/8] feat: add TTL'd cross-process terminal width cache Persists probed widths to ~/.cache/ccstatusline/terminal-width.json, keyed by session_id (width is per-terminal, so a global value would be wrong). Atomic tmp+rename writes, entries pruned after an hour, corrupt/unwritable cache treated as a miss and never fatal. readCachedWidth returns a wrapper ({width} | null) so a cached null -- 'we probed, there is no TTY' -- is a HIT, not a miss. Collapsing that would make the no-TTY case re-probe forever, which is the bug this exists to fix. ttlSeconds 0 disables caching (always probe). Note this deliberately differs from gitCacheTtlSeconds, where 0 means 'never expire'. Co-Authored-By: Claude Opus 4.8 --- .../__tests__/terminal-width-cache.test.ts | 118 +++++++++++++++ src/utils/terminal-width-cache.ts | 138 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 src/utils/__tests__/terminal-width-cache.test.ts create mode 100644 src/utils/terminal-width-cache.ts diff --git a/src/utils/__tests__/terminal-width-cache.test.ts b/src/utils/__tests__/terminal-width-cache.test.ts new file mode 100644 index 00000000..3eb5b1ba --- /dev/null +++ b/src/utils/__tests__/terminal-width-cache.test.ts @@ -0,0 +1,118 @@ +import { + beforeEach, + describe, + expect, + it +} from 'vitest'; + +import type { WidthCacheDeps } from '../terminal-width-cache'; +import { + readCachedWidth, + writeCachedWidth +} from '../terminal-width-cache'; + +const CACHE_PATH = '/tmp/test-terminal-width.json'; + +function makeDeps(initial: string | null, now = 1_000_000): WidthCacheDeps & { files: Map } { + const files = new Map(); + if (initial !== null) { + files.set(CACHE_PATH, initial); + } + + return { + files, + cachePath: CACHE_PATH, + now: () => now, + mkdirSync: () => undefined, + readFileSync: (p: string) => { + const content = files.get(p); + if (content === undefined) { + throw new Error('ENOENT'); + } + + return content; + }, + writeFileSync: (p: string, data: string) => { files.set(p, data); }, + renameSync: (from: string, to: string) => { + const data = files.get(from); + if (data === undefined) { + throw new Error('ENOENT'); + } + + files.set(to, data); + files.delete(from); + } + }; +} + +describe('terminal width cache', () => { + let deps: ReturnType; + + beforeEach(() => { + deps = makeDeps(null); + }); + + it('returns null on a cache miss', () => { + expect(readCachedWidth('session-a', 5, deps)).toBeNull(); + }); + + it('round-trips a width within the TTL', () => { + writeCachedWidth('session-a', 209, deps); + expect(readCachedWidth('session-a', 5, deps)).toEqual({ width: 209 }); + }); + + // The whole point: a cached "no tty" must be a HIT, not a miss, or the + // expensive no-tty case re-probes forever. + it('round-trips a cached null width as a hit', () => { + writeCachedWidth('session-a', null, deps); + expect(readCachedWidth('session-a', 5, deps)).toEqual({ width: null }); + }); + + it('keys entries by session so sessions do not share a width', () => { + writeCachedWidth('session-a', 209, deps); + writeCachedWidth('session-b', 80, deps); + expect(readCachedWidth('session-a', 5, deps)).toEqual({ width: 209 }); + expect(readCachedWidth('session-b', 5, deps)).toEqual({ width: 80 }); + }); + + it('treats an entry older than the TTL as a miss', () => { + writeCachedWidth('session-a', 209, deps); + const later = makeDeps(deps.files.get(CACHE_PATH) ?? null, 1_000_000 + 6_000); + expect(readCachedWidth('session-a', 5, later)).toBeNull(); + }); + + it('treats ttlSeconds of 0 as caching disabled (always a miss)', () => { + writeCachedWidth('session-a', 209, deps); + expect(readCachedWidth('session-a', 0, deps)).toBeNull(); + }); + + it('treats a corrupt cache file as a miss and does not throw', () => { + const corrupt = makeDeps('{ this is not json'); + expect(() => readCachedWidth('session-a', 5, corrupt)).not.toThrow(); + expect(readCachedWidth('session-a', 5, corrupt)).toBeNull(); + }); + + it('never throws when the cache is unwritable', () => { + const unwritable = makeDeps(null); + unwritable.writeFileSync = () => { throw new Error('EACCES'); }; + expect(() => { writeCachedWidth('session-a', 209, unwritable); }).not.toThrow(); + }); + + it('prunes entries older than an hour on write', () => { + writeCachedWidth('stale-session', 100, deps); + const muchLater = makeDeps(deps.files.get(CACHE_PATH) ?? null, 1_000_000 + 3_600_001); + writeCachedWidth('fresh-session', 209, muchLater); + + const written = JSON.parse(muchLater.files.get(CACHE_PATH) ?? '{}') as { entries: Record }; + expect(Object.keys(written.entries)).toEqual(['fresh-session']); + }); + + it('writes atomically via a temp file and rename', () => { + const renames: string[] = []; + const tracking = makeDeps(null); + tracking.renameSync = (from: string, to: string) => { renames.push(`${from}->${to}`); }; + writeCachedWidth('session-a', 209, tracking); + expect(renames).toHaveLength(1); + expect(renames[0]).toContain(`->${CACHE_PATH}`); + }); +}); diff --git a/src/utils/terminal-width-cache.ts b/src/utils/terminal-width-cache.ts new file mode 100644 index 00000000..76f35073 --- /dev/null +++ b/src/utils/terminal-width-cache.ts @@ -0,0 +1,138 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const CACHE_SCHEMA_VERSION = 1 as const; +const PRUNE_AFTER_MS = 60 * 60 * 1000; + +interface WidthCacheEntry { + width: number | null; + createdAt: number; +} + +interface PersistentWidthCache { + version: typeof CACHE_SCHEMA_VERSION; + entries: Record; +} + +export interface WidthCacheDeps { + readFileSync: (path: string) => string; + writeFileSync: (path: string, data: string) => void; + renameSync: (from: string, to: string) => void; + mkdirSync: (path: string) => void; + now: () => number; + cachePath: string; +} + +function defaultCachePath(): string { + return path.join(os.homedir(), '.cache', 'ccstatusline', 'terminal-width.json'); +} + +const defaultDeps: WidthCacheDeps = { + readFileSync: (p: string) => fs.readFileSync(p, 'utf-8'), + 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 }); }, + now: () => Date.now(), + cachePath: defaultCachePath() +}; + +function isEntry(value: unknown): value is WidthCacheEntry { + if (typeof value !== 'object' || value === null) { + return false; + } + + const entry = value as Record; + return (typeof entry.width === 'number' || entry.width === null) + && typeof entry.createdAt === 'number'; +} + +function readCache(deps: WidthCacheDeps): PersistentWidthCache { + const empty: PersistentWidthCache = { version: CACHE_SCHEMA_VERSION, entries: {} }; + try { + const parsed = JSON.parse(deps.readFileSync(deps.cachePath)) as unknown; + if (typeof parsed !== 'object' || parsed === null) { + return empty; + } + + const data = parsed as { version?: unknown; entries?: unknown }; + if (data.version !== CACHE_SCHEMA_VERSION || typeof data.entries !== 'object' || data.entries === null) { + return empty; + } + + const entries: Record = {}; + for (const [key, value] of Object.entries(data.entries)) { + if (isEntry(value)) { + entries[key] = value; + } + } + + return { version: CACHE_SCHEMA_VERSION, entries }; + } catch { + // Missing or corrupt cache is a miss, never a failure. + return empty; + } +} + +/** + * Read a cached width for this session. + * + * Returns null on miss/expiry/corruption, or a wrapper on hit. The wrapper + * matters: a cached width of `null` means "we probed and there is no TTY", + * which is a legitimate hit. Collapsing that to a bare null would make the + * no-TTY case -- the expensive one -- re-probe on every render. + * + * ttlSeconds of 0 disables the cache (always a miss). NOTE: this differs from + * gitCacheTtlSeconds, where 0 means "never expire". Divergence is intentional. + */ +export function readCachedWidth( + sessionId: string, + ttlSeconds: number, + deps: WidthCacheDeps = defaultDeps +): { width: number | null } | null { + if (ttlSeconds <= 0) { + return null; + } + + const entry = readCache(deps).entries[sessionId]; + if (!entry) { + return null; + } + + if (deps.now() - entry.createdAt > ttlSeconds * 1000) { + return null; + } + + return { width: entry.width }; +} + +/** Persist a probed width (including a null "no TTY" result). Best-effort; never throws. */ +export function writeCachedWidth( + sessionId: string, + width: number | null, + deps: WidthCacheDeps = defaultDeps +): void { + try { + const now = deps.now(); + const existing = readCache(deps); + + // Prune entries older than an hour so the file cannot grow without + // bound across many sessions. + const entries: Record = {}; + for (const [key, entry] of Object.entries(existing.entries)) { + if (now - entry.createdAt <= PRUNE_AFTER_MS) { + entries[key] = entry; + } + } + + entries[sessionId] = { width, createdAt: now }; + const cache: PersistentWidthCache = { version: CACHE_SCHEMA_VERSION, entries }; + + deps.mkdirSync(path.dirname(deps.cachePath)); + const tempPath = `${deps.cachePath}.${process.pid}.tmp`; + deps.writeFileSync(tempPath, JSON.stringify(cache)); + deps.renameSync(tempPath, deps.cachePath); + } catch { + // Best-effort cache; the statusline must render regardless. + } +} From 5ee08c86e1166938fc56cadd57462229aeb0ceba Mon Sep 17 00:00:00 2001 From: elhoim Date: Mon, 13 Jul 2026 09:21:54 +0000 Subject: [PATCH 3/8] perf: memoize terminal width probe, including the null result Claude Code spawns the statusline without a TTY, so probeTerminalWidth() returns null. Callers read it as 'context.terminalWidth ?? getTerminalWidth()', so the null re-triggered the full ps/stty ancestor walk once per configured line. Cache the probe behind an explicit hasProbed flag so the negative result is memoized too. Co-Authored-By: Claude Opus 4.8 --- src/utils/__tests__/terminal.test.ts | 67 +++++++++++++++++++++++++++- src/utils/terminal.ts | 24 +++++++++- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/utils/__tests__/terminal.test.ts b/src/utils/__tests__/terminal.test.ts index 32e22e01..df1b663a 100644 --- a/src/utils/__tests__/terminal.test.ts +++ b/src/utils/__tests__/terminal.test.ts @@ -10,7 +10,8 @@ import { import { canDetectTerminalWidth, - getTerminalWidth + getTerminalWidth, + resetTerminalWidthCache } from '../terminal'; vi.mock('child_process', () => ({ @@ -45,6 +46,10 @@ describe('terminal utils', () => { delete process.env.CCSTATUSLINE_WIDTH; }); + beforeEach(() => { + resetTerminalWidthCache(); + }); + afterEach(() => { vi.restoreAllMocks(); delete process.env.CCSTATUSLINE_WIDTH; @@ -279,4 +284,64 @@ describe('terminal utils', () => { expect(canDetectTerminalWidth()).toBe(false); expect(mockExecSync.mock.calls.length).toBe(0); }); + + it('probes only once across repeated calls when a width is found', () => { + pinPosixPlatform(); + mockExecSync.mockImplementation((command: string) => { + if (command === `ps -o ppid= -p ${process.pid}`) { + return '1234\n'; + } + + if (command === 'ps -o tty= -p 1234') { + return 'ttys001\n'; + } + + if (command === `stty -F /dev/ttys001 size 2>/dev/null | awk '{print $2}'`) { + return '120\n'; + } + + throw new Error(`Unexpected command: ${command}`); + }); + + expect(getTerminalWidth()).toBe(120); + expect(getTerminalWidth()).toBe(120); + expect(canDetectTerminalWidth()).toBe(true); + + const ppidProbes = mockExecSync.mock.calls.filter( + call => typeof call[0] === 'string' && call[0].startsWith('ps -o ppid=') + ); + expect(ppidProbes).toHaveLength(1); + }); + + // Regression test for the 113-spawn bug: Claude Code spawns the statusline with + // no TTY, so the probe returns null. Callers use `context.terminalWidth ?? + // getTerminalWidth()`, so a memo that does not cache null re-probes forever. + it('probes only once when NO tty is found (null is memoized)', () => { + pinPosixPlatform(); + mockExecSync.mockImplementation(() => { + throw new Error('no tty anywhere'); + }); + + expect(getTerminalWidth()).toBeNull(); + expect(getTerminalWidth()).toBeNull(); + expect(getTerminalWidth()).toBeNull(); + expect(canDetectTerminalWidth()).toBe(false); + + const ppidProbes = mockExecSync.mock.calls.filter( + call => typeof call[0] === 'string' && call[0].startsWith('ps -o ppid=') + ); + expect(ppidProbes).toHaveLength(1); + }); + + it('resetTerminalWidthCache forces a re-probe', () => { + pinPosixPlatform(); + process.env.CCSTATUSLINE_WIDTH = '150'; + expect(getTerminalWidth()).toBe(150); + + process.env.CCSTATUSLINE_WIDTH = '175'; + expect(getTerminalWidth()).toBe(150); // memoized, not re-read + + resetTerminalWidthCache(); + expect(getTerminalWidth()).toBe(175); + }); }); diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index 57429ba5..0e949913 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -168,12 +168,32 @@ function getWidthForTTY(tty: string): number | null { return null; } +// Memoized probe result. `hasProbed` is a separate flag rather than a +// `null`-check on `cachedWidth`, because `null` (no TTY found) is a real, +// cacheable answer -- and the common one: Claude Code spawns the statusline +// with no controlling terminal. Callers do `context.terminalWidth ?? +// getTerminalWidth()`, so treating `null` as "not yet probed" would re-run the +// full ancestor walk on every line of every render. +let hasProbed = false; +let cachedWidth: number | null = null; + +/** Clear the memoized width. For tests, and for the TUI to re-probe after a resize. */ +export function resetTerminalWidthCache(): void { + hasProbed = false; + cachedWidth = null; +} + // Get terminal width export function getTerminalWidth(): number | null { - return probeTerminalWidth(); + if (!hasProbed) { + cachedWidth = probeTerminalWidth(); + hasProbed = true; + } + + return cachedWidth; } // Check if terminal width detection is available export function canDetectTerminalWidth(): boolean { - return probeTerminalWidth() !== null; + return getTerminalWidth() !== null; } From ff86725dfe28a9592157a4b4828a5168b6afb3ae Mon Sep 17 00:00:00 2001 From: elhoim Date: Mon, 13 Jul 2026 09:40:28 +0000 Subject: [PATCH 4/8] perf: use execFileSync instead of a shell in the width fallback execSync with shell:'/bin/sh' spawned a shell in addition to the command, doubling every probe; the stty|awk pipe cost three processes. Call the binaries directly and parse 'rows cols' in JS. Drops the legacy 'stty size < /dev/tty' redirect form, which required a shell and is redundant with the -F/-f forms. Co-Authored-By: Claude Opus 4.8 --- src/utils/__tests__/terminal.test.ts | 154 +++++++++++++-------------- src/utils/terminal.ts | 30 +++--- 2 files changed, 91 insertions(+), 93 deletions(-) diff --git a/src/utils/__tests__/terminal.test.ts b/src/utils/__tests__/terminal.test.ts index df1b663a..22f34d54 100644 --- a/src/utils/__tests__/terminal.test.ts +++ b/src/utils/__tests__/terminal.test.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; import { afterEach, beforeEach, @@ -21,9 +21,9 @@ vi.mock('child_process', () => ({ })); describe('terminal utils', () => { - const mockExecSync = execSync as unknown as { + const mockExecFileSync = execFileSync as unknown as { mock: { calls: unknown[][] }; - mockImplementation: (impl: (command: string) => string) => void; + mockImplementation: (impl: (file: string, args: string[]) => string) => void; mockImplementationOnce: (impl: () => never) => void; mockReturnValueOnce: (value: string) => void; }; @@ -58,54 +58,54 @@ describe('terminal utils', () => { it('returns width from the immediate parent tty when available', () => { pinPosixPlatform(); - mockExecSync.mockImplementation((command: string) => { - if (command === `ps -o ppid= -p ${process.pid}`) { + mockExecFileSync.mockImplementation((file: string, args: string[]) => { + if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { return '1234\n'; } - if (command === 'ps -o tty= -p 1234') { + if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { return 'ttys001\n'; } - if (command === `stty -F /dev/ttys001 size 2>/dev/null | awk '{print $2}'`) { - return '120\n'; + if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { + return '24 120\n'; } - throw new Error(`Unexpected command: ${command}`); + throw new Error(`Unexpected command: ${file} ${args.join(' ')}`); }); expect(getTerminalWidth()).toBe(120); - expect(mockExecSync.mock.calls.map(([command]) => command)).toEqual([ + expect(mockExecFileSync.mock.calls.map(([file, args]) => `${file as string} ${(args as string[]).join(' ')}`)).toEqual([ `ps -o ppid= -p ${process.pid}`, 'ps -o tty= -p 1234', - `stty -F /dev/ttys001 size 2>/dev/null | awk '{print $2}'` + 'stty -F /dev/ttys001 size' ]); }); it('walks ancestor processes until it finds a valid tty', () => { pinPosixPlatform(); - mockExecSync.mockImplementation((command: string) => { - if (command === `ps -o ppid= -p ${process.pid}`) { + mockExecFileSync.mockImplementation((file: string, args: string[]) => { + if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { return '1234\n'; } - if (command === 'ps -o tty= -p 1234') { + if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { return '??\n'; } - if (command === 'ps -o ppid= -p 1234') { + if (file === 'ps' && args.join(' ') === '-o ppid= -p 1234') { return '5678\n'; } - if (command === 'ps -o tty= -p 5678') { + if (file === 'ps' && args.join(' ') === '-o tty= -p 5678') { return ' ttys009 \n'; } - if (command === `stty -F /dev/ttys009 size 2>/dev/null | awk '{print $2}'`) { - return '104\n'; + if (file === 'stty' && args.join(' ') === '-F /dev/ttys009 size') { + return '24 104\n'; } - throw new Error(`Unexpected command: ${command}`); + throw new Error(`Unexpected command: ${file} ${args.join(' ')}`); }); expect(getTerminalWidth()).toBe(104); @@ -113,26 +113,25 @@ describe('terminal utils', () => { it('falls back through stty variants when the first form returns no value', () => { pinPosixPlatform(); - // Simulates BSD/macOS, where `stty -F` exits with an error and yields - // empty output via the `2>/dev/null | awk` pipeline; `stty -f` succeeds. - mockExecSync.mockImplementation((command: string) => { - if (command === `ps -o ppid= -p ${process.pid}`) { + // Simulates BSD/macOS, where `stty -F` exits with an error; `stty -f` succeeds. + mockExecFileSync.mockImplementation((file: string, args: string[]) => { + if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { return '1234\n'; } - if (command === 'ps -o tty= -p 1234') { + if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { return 'ttys003\n'; } - if (command === `stty -F /dev/ttys003 size 2>/dev/null | awk '{print $2}'`) { - return '\n'; + if (file === 'stty' && args.join(' ') === '-F /dev/ttys003 size') { + throw new Error('stty: invalid argument'); } - if (command === `stty -f /dev/ttys003 size 2>/dev/null | awk '{print $2}'`) { - return '142\n'; + if (file === 'stty' && args.join(' ') === '-f /dev/ttys003 size') { + return '24 142\n'; } - throw new Error(`Unexpected command: ${command}`); + throw new Error(`Unexpected command: ${file} ${args.join(' ')}`); }); expect(getTerminalWidth()).toBe(142); @@ -140,39 +139,38 @@ describe('terminal utils', () => { it('falls back to tput cols when ancestor probing fails', () => { pinPosixPlatform(); - mockExecSync.mockImplementationOnce(() => { throw new Error('ps unavailable'); }); - mockExecSync.mockReturnValueOnce('90\n'); + mockExecFileSync.mockImplementationOnce(() => { throw new Error('ps unavailable'); }); + mockExecFileSync.mockReturnValueOnce('90\n'); expect(getTerminalWidth()).toBe(90); - expect(mockExecSync.mock.calls[1]?.[0]).toBe('tput cols 2>/dev/null'); + expect(mockExecFileSync.mock.calls[1]?.[0]).toBe('tput'); + expect(mockExecFileSync.mock.calls[1]?.[1]).toEqual(['cols']); }); it('returns null when ancestor and fallback probes fail', () => { pinPosixPlatform(); - mockExecSync.mockImplementation((command: string) => { - if (command === `ps -o ppid= -p ${process.pid}`) { + mockExecFileSync.mockImplementation((file: string, args: string[]) => { + if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { return '1234\n'; } - if (command === 'ps -o tty= -p 1234') { + if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { return 'ttys001\n'; } - if (command === `stty -F /dev/ttys001 size 2>/dev/null | awk '{print $2}'` - || command === `stty -f /dev/ttys001 size 2>/dev/null | awk '{print $2}'` - || command === `stty size < /dev/ttys001 2>/dev/null | awk '{print $2}'`) { - return 'not-a-number\n'; + if (file === 'stty') { + return 'not-a-number not-a-number\n'; } - if (command === 'ps -o ppid= -p 1234') { + if (file === 'ps' && args.join(' ') === '-o ppid= -p 1234') { return '0\n'; } - if (command === 'tput cols 2>/dev/null') { + if (file === 'tput') { throw new Error('tput unavailable'); } - throw new Error(`Unexpected command: ${command}`); + throw new Error(`Unexpected command: ${file} ${args.join(' ')}`); }); expect(getTerminalWidth()).toBeNull(); @@ -180,28 +178,28 @@ describe('terminal utils', () => { it('detects availability when an ancestor tty probe succeeds', () => { pinPosixPlatform(); - mockExecSync.mockImplementation((command: string) => { - if (command === `ps -o ppid= -p ${process.pid}`) { + mockExecFileSync.mockImplementation((file: string, args: string[]) => { + if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { return '1234\n'; } - if (command === 'ps -o tty= -p 1234') { + if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { return '??\n'; } - if (command === 'ps -o ppid= -p 1234') { + if (file === 'ps' && args.join(' ') === '-o ppid= -p 1234') { return '5678\n'; } - if (command === 'ps -o tty= -p 5678') { + if (file === 'ps' && args.join(' ') === '-o tty= -p 5678') { return 'ttys010\n'; } - if (command === `stty -F /dev/ttys010 size 2>/dev/null | awk '{print $2}'`) { - return '80\n'; + if (file === 'stty' && args.join(' ') === '-F /dev/ttys010 size') { + return '24 80\n'; } - throw new Error(`Unexpected command: ${command}`); + throw new Error(`Unexpected command: ${file} ${args.join(' ')}`); }); expect(canDetectTerminalWidth()).toBe(true); @@ -209,8 +207,8 @@ describe('terminal utils', () => { it('returns false for availability when all probes fail', () => { pinPosixPlatform(); - mockExecSync.mockImplementationOnce(() => { throw new Error('tty unavailable'); }); - mockExecSync.mockImplementationOnce(() => { throw new Error('tput unavailable'); }); + mockExecFileSync.mockImplementationOnce(() => { throw new Error('tty unavailable'); }); + mockExecFileSync.mockImplementationOnce(() => { throw new Error('tput unavailable'); }); expect(canDetectTerminalWidth()).toBe(false); }); @@ -219,27 +217,27 @@ describe('terminal utils', () => { process.env.CCSTATUSLINE_WIDTH = '220'; expect(getTerminalWidth()).toBe(220); - expect(mockExecSync.mock.calls.length).toBe(0); + expect(mockExecFileSync.mock.calls.length).toBe(0); }); it('ignores a non-positive CCSTATUSLINE_WIDTH and falls back to probing', () => { pinPosixPlatform(); process.env.CCSTATUSLINE_WIDTH = '0'; - mockExecSync.mockImplementation((command: string) => { - if (command === `ps -o ppid= -p ${process.pid}`) { + mockExecFileSync.mockImplementation((file: string, args: string[]) => { + if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { return '1234\n'; } - if (command === 'ps -o tty= -p 1234') { + if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { return 'ttys001\n'; } - if (command === `stty -F /dev/ttys001 size 2>/dev/null | awk '{print $2}'`) { - return '160\n'; + if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { + return '24 160\n'; } - throw new Error(`Unexpected command: ${command}`); + throw new Error(`Unexpected command: ${file} ${args.join(' ')}`); }); expect(getTerminalWidth()).toBe(160); @@ -249,20 +247,20 @@ describe('terminal utils', () => { pinPosixPlatform(); process.env.CCSTATUSLINE_WIDTH = 'wide'; - mockExecSync.mockImplementation((command: string) => { - if (command === `ps -o ppid= -p ${process.pid}`) { + mockExecFileSync.mockImplementation((file: string, args: string[]) => { + if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { return '1234\n'; } - if (command === 'ps -o tty= -p 1234') { + if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { return 'ttys001\n'; } - if (command === `stty -F /dev/ttys001 size 2>/dev/null | awk '{print $2}'`) { - return '160\n'; + if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { + return '24 160\n'; } - throw new Error(`Unexpected command: ${command}`); + throw new Error(`Unexpected command: ${file} ${args.join(' ')}`); }); expect(getTerminalWidth()).toBe(160); @@ -274,7 +272,7 @@ describe('terminal utils', () => { expect(getTerminalWidth()).toBe(180); expect(canDetectTerminalWidth()).toBe(true); - expect(mockExecSync.mock.calls.length).toBe(0); + expect(mockExecFileSync.mock.calls.length).toBe(0); }); it('disables width detection on Windows', () => { @@ -282,33 +280,33 @@ describe('terminal utils', () => { expect(getTerminalWidth()).toBeNull(); expect(canDetectTerminalWidth()).toBe(false); - expect(mockExecSync.mock.calls.length).toBe(0); + expect(mockExecFileSync.mock.calls.length).toBe(0); }); it('probes only once across repeated calls when a width is found', () => { pinPosixPlatform(); - mockExecSync.mockImplementation((command: string) => { - if (command === `ps -o ppid= -p ${process.pid}`) { + mockExecFileSync.mockImplementation((file: string, args: string[]) => { + if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { return '1234\n'; } - if (command === 'ps -o tty= -p 1234') { + if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { return 'ttys001\n'; } - if (command === `stty -F /dev/ttys001 size 2>/dev/null | awk '{print $2}'`) { - return '120\n'; + if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { + return '24 120\n'; } - throw new Error(`Unexpected command: ${command}`); + throw new Error(`Unexpected command: ${file} ${args.join(' ')}`); }); expect(getTerminalWidth()).toBe(120); expect(getTerminalWidth()).toBe(120); expect(canDetectTerminalWidth()).toBe(true); - const ppidProbes = mockExecSync.mock.calls.filter( - call => typeof call[0] === 'string' && call[0].startsWith('ps -o ppid=') + const ppidProbes = mockExecFileSync.mock.calls.filter( + call => call[0] === 'ps' && Array.isArray(call[1]) && (call[1])[1] === 'ppid=' ); expect(ppidProbes).toHaveLength(1); }); @@ -318,7 +316,7 @@ describe('terminal utils', () => { // getTerminalWidth()`, so a memo that does not cache null re-probes forever. it('probes only once when NO tty is found (null is memoized)', () => { pinPosixPlatform(); - mockExecSync.mockImplementation(() => { + mockExecFileSync.mockImplementation(() => { throw new Error('no tty anywhere'); }); @@ -327,8 +325,8 @@ describe('terminal utils', () => { expect(getTerminalWidth()).toBeNull(); expect(canDetectTerminalWidth()).toBe(false); - const ppidProbes = mockExecSync.mock.calls.filter( - call => typeof call[0] === 'string' && call[0].startsWith('ps -o ppid=') + const ppidProbes = mockExecFileSync.mock.calls.filter( + call => call[0] === 'ps' && Array.isArray(call[1]) && (call[1])[1] === 'ppid=' ); expect(ppidProbes).toHaveLength(1); }); diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index 0e949913..689ee9c8 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; @@ -78,7 +78,7 @@ function probeTerminalWidth(): number | null { // Fallback: try tput cols which might work in some environments try { - const width = execSync('tput cols 2>/dev/null', { + const width = execFileSync('tput', ['cols'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true @@ -103,10 +103,9 @@ function parsePositiveInteger(value: string): number | null { function getParentProcessId(pid: number): number | null { try { - const parentPidOutput = execSync(`ps -o ppid= -p ${pid}`, { + const parentPidOutput = execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], - shell: '/bin/sh', windowsHide: true }).trim(); @@ -118,10 +117,9 @@ function getParentProcessId(pid: number): number | null { function getTTYForProcess(pid: number): string | null { try { - const tty = execSync(`ps -o tty= -p ${pid}`, { + const tty = execFileSync('ps', ['-o', 'tty=', '-p', String(pid)], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], - shell: '/bin/sh', windowsHide: true }).replace(/\s+/g, ''); @@ -140,23 +138,25 @@ function getWidthForTTY(tty: string): number | null { // when the calling process has no controlling terminal — the case under // Claude Code >= 2.1.139, which spawns statusline/hooks without terminal // access. `stty -F` / `-f` ask stty to open the device itself (with - // O_NOCTTY semantics) and succeed regardless of controlling-tty status. + // O_NOCTTY semantics) and succeed regardless of controlling-tty status, + // so the legacy redirect form (which also required a shell) is dropped. + // The "rows cols" output is parsed here rather than piped through awk: + // no shell, one process instead of three. const devicePath = `/dev/${tty}`; - const attempts = [ - `stty -F ${devicePath} size`, // GNU coreutils (Linux) - `stty -f ${devicePath} size`, // BSD stty (macOS, *BSD) - `stty size < ${devicePath}` // legacy fallback + const attempts: string[][] = [ + ['-F', devicePath, 'size'], // GNU coreutils (Linux) + ['-f', devicePath, 'size'] // BSD stty (macOS, *BSD) ]; - for (const cmd of attempts) { + for (const args of attempts) { try { - const width = execSync(`${cmd} 2>/dev/null | awk '{print $2}'`, { + const output = execFileSync('stty', args, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'], - shell: '/bin/sh', windowsHide: true }).trim(); - const parsed = parsePositiveInteger(width); + + const parsed = parsePositiveInteger(output.split(/\s+/)[1] ?? ''); if (parsed !== null) { return parsed; } From 65d3338ea4e4e7d6ebe391541a0922c10e7b7434 Mon Sep 17 00:00:00 2001 From: elhoim Date: Mon, 13 Jul 2026 10:08:56 +0000 Subject: [PATCH 5/8] feat: wire the native probe and width cache into the render path Chain is now: CCSTATUSLINE_WIDTH override -> in-process memo -> shared L2 disk cache -> probe (native /proc+TIOCGWINSZ on Linux, ps/stty/tput elsewhere). Only the entry point supplies a session_id, so only it reads or writes the shared cache; renderer.ts and widgets/TerminalWidth.ts keep their zero-arg calls and hit the memo. Adds terminalWidthCacheTtlSeconds (default 5, 0 disables) to Settings and RenderContext, and backfills it in two widget test fixtures that build a full Settings literal (zod .default() makes the key required on the output type). Reads process.platform at call time in terminal-native's default deps: a module-load snapshot ignored the per-test platform pin and ran the real Linux probe under darwin-pinned tests. Co-Authored-By: Claude Opus 4.8 --- src/ccstatusline.ts | 8 ++- src/types/RenderContext.ts | 1 + src/types/Settings.ts | 6 +++ src/utils/terminal-native.ts | 6 ++- src/utils/terminal.ts | 51 +++++++++++++++++-- .../__tests__/CurrentWorkingDir.test.ts | 1 + src/widgets/__tests__/CustomCommand.test.ts | 1 + 7 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index f938ab42..97385339 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -167,10 +167,14 @@ async function renderMultipleLines(data: StatusJSON) { sessionDuration, skillsMetrics, compactionData, - terminalWidth: getTerminalWidth(), + terminalWidth: getTerminalWidth({ + sessionId: data.session_id, + ttlSeconds: settings.terminalWidthCacheTtlSeconds + }), isPreview: false, minimalist: settings.minimalistMode, - gitCacheTtlSeconds: settings.gitCacheTtlSeconds + gitCacheTtlSeconds: settings.gitCacheTtlSeconds, + terminalWidthCacheTtlSeconds: settings.terminalWidthCacheTtlSeconds }; // Always pre-render all widgets once (for efficiency) diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 78fd6308..8ee0ebff 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -44,6 +44,7 @@ export interface RenderContext { isPreview?: boolean; minimalist?: boolean; gitCacheTtlSeconds?: number; + terminalWidthCacheTtlSeconds?: number; lineIndex?: number; // Index of the current line being rendered (for theme cycling) globalSeparatorIndex?: number; // Global separator index that continues across lines diff --git a/src/types/Settings.ts b/src/types/Settings.ts index efa2a31c..f752cd46 100644 --- a/src/types/Settings.ts +++ b/src/types/Settings.ts @@ -69,6 +69,12 @@ export const SettingsSchema = z.object({ overrideForegroundColor: z.string().optional(), globalBold: z.boolean().default(false), gitCacheTtlSeconds: z.number().min(0).max(60).default(5), + // How long a probed terminal width is reused, in seconds. The probe is the + // most expensive thing a render does, so the result is shared across renders + // and sessions via ~/.cache/ccstatusline/terminal-width.json. + // NOTE: 0 disables the cache (always probe). This deliberately differs from + // gitCacheTtlSeconds above, where 0 means "never expire". + terminalWidthCacheTtlSeconds: z.number().min(0).max(300).default(5), minimalistMode: z.boolean().default(false), powerline: PowerlineConfigSchema.default({ enabled: false, diff --git a/src/utils/terminal-native.ts b/src/utils/terminal-native.ts index 8f15e88f..dc490e82 100644 --- a/src/utils/terminal-native.ts +++ b/src/utils/terminal-native.ts @@ -26,7 +26,11 @@ const defaultDeps: NativeProbeDeps = { const columns = stream.columns; return typeof columns === 'number' && columns > 0 ? columns : null; }, - platform: process.platform + // Read at call time, not module-load time: tests pin process.platform per + // case, and a snapshot taken at import would ignore the pin. + get platform(): string { + return process.platform; + } }; /** diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index 689ee9c8..d574ea8c 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -2,6 +2,12 @@ import { execFileSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import { probeWidthNative } from './terminal-native'; +import { + readCachedWidth, + writeCachedWidth +} from './terminal-width-cache'; + // Get package version // __PACKAGE_VERSION__ will be replaced at build time const PACKAGE_VERSION = '__PACKAGE_VERSION__'; @@ -53,6 +59,13 @@ function probeTerminalWidth(): number | null { return null; } + // Zero-subprocess path (Linux): /proc ancestry + TIOCGWINSZ. Returns null on + // other platforms and falls through to the portable ps/stty/tput walk below. + const nativeWidth = probeWidthNative(); + if (nativeWidth !== null) { + return nativeWidth; + } + // Claude Code can spawn ccstatusline with piped stdio, leaving the immediate // parent process without a controlling TTY. Walk up a few ancestors until we // find the shell process that owns the real PTY. @@ -183,11 +196,39 @@ export function resetTerminalWidthCache(): void { cachedWidth = null; } -// Get terminal width -export function getTerminalWidth(): number | null { - if (!hasProbed) { - cachedWidth = probeTerminalWidth(); - hasProbed = true; +export interface TerminalWidthOptions { + sessionId?: string; + ttlSeconds?: number; +} + +// Get terminal width. +// +// Callers that pass no options (renderer.ts, widgets/TerminalWidth.ts) read the +// in-process memo, which the entry point has already populated. Only the entry +// point supplies a session, so only it consults or writes the shared L2 cache. +export function getTerminalWidth(options?: TerminalWidthOptions): number | null { + if (hasProbed) { + return cachedWidth; + } + + const sessionId = options?.sessionId; + const ttlSeconds = options?.ttlSeconds ?? 0; + + // L2: another process in this session may have already paid for the probe. + if (sessionId) { + const cached = readCachedWidth(sessionId, ttlSeconds); + if (cached) { + cachedWidth = cached.width; + hasProbed = true; + return cachedWidth; + } + } + + cachedWidth = probeTerminalWidth(); + hasProbed = true; + + if (sessionId && ttlSeconds > 0) { + writeCachedWidth(sessionId, cachedWidth); } return cachedWidth; diff --git a/src/widgets/__tests__/CurrentWorkingDir.test.ts b/src/widgets/__tests__/CurrentWorkingDir.test.ts index 218988ba..ddcc6036 100644 --- a/src/widgets/__tests__/CurrentWorkingDir.test.ts +++ b/src/widgets/__tests__/CurrentWorkingDir.test.ts @@ -42,6 +42,7 @@ describe('CurrentWorkingDirWidget', () => { inheritSeparatorColors: false, globalBold: false, gitCacheTtlSeconds: 5, + terminalWidthCacheTtlSeconds: 5, minimalistMode: false, powerline: { enabled: false, diff --git a/src/widgets/__tests__/CustomCommand.test.ts b/src/widgets/__tests__/CustomCommand.test.ts index c30e86e6..52827cf6 100644 --- a/src/widgets/__tests__/CustomCommand.test.ts +++ b/src/widgets/__tests__/CustomCommand.test.ts @@ -28,6 +28,7 @@ describe('CustomCommandWidget', () => { inheritSeparatorColors: false, globalBold: false, gitCacheTtlSeconds: 5, + terminalWidthCacheTtlSeconds: 5, minimalistMode: false, powerline: { enabled: false, From d4361136626d2656e611c39eba1f4c55eb0c5772 Mon Sep 17 00:00:00 2001 From: elhoim Date: Mon, 13 Jul 2026 12:17:50 +0000 Subject: [PATCH 6/8] docs: document terminalWidthCacheTtlSeconds in README Co-Authored-By: Claude Opus 4.8 --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index d5c6d2aa..d62f724f 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,13 @@ ## 🆕 Recent Updates +### Unreleased - Terminal width probing no longer spawns 100+ processes per render + +- **⚡ Zero-subprocess width detection** - On Linux the terminal width is now read from `/proc` and `TIOCGWINSZ` instead of shelling out. A render went from **122 spawned processes to 1** (`node` itself) and from **4.37 CPU-seconds to 1.08** on the benchmark payload. macOS/BSD keep the portable `ps`/`stty`/`tput` walk, now invoked without a `/bin/sh` wrapper. +- **🎯 Correct width, not just faster** - The old `tput cols` fallback reported its no-TTY default of 80 columns on terminals that were actually 209 wide. The new probe finds the real pty and reports the true width. +- **♻️ The width is cached** - Claude Code spawns the status line without a TTY, so the probe returned `null`; because callers read it as `context.terminalWidth ?? getTerminalWidth()`, that `null` re-ran the whole ancestor walk **once per configured line, on every render**. The result (including a `null`) is now memoized in-process and shared across renders and sessions via `~/.cache/ccstatusline/terminal-width.json`. +- **⏱️ `terminalWidthCacheTtlSeconds`** - New setting, default `5`, range 0–300. Controls how long a probed width is reused; resize your terminal and the status line corrects itself within this many seconds. Set to `0` to probe on every render. (Note: unlike `gitCacheTtlSeconds`, where `0` means "never expire", `0` here disables the cache.) + ### v2.2.22 - v2.2.23 - Powerline flex mode, layout controls, composable metrics, and safer config ![Powerline Flex Mode](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/powerline-flex.png) From fbe19fc476530b394f262f230752afbd7bbdfc15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Andr=C3=A9?= Date: Tue, 21 Jul 2026 14:27:52 +0000 Subject: [PATCH 7/8] Fix code-review findings in the terminal-width caching feature - Serialize writes to the persistent width cache with a best-effort file lock (O_CREAT|O_EXCL, stale-lock recovery), closing a read-modify-write race where two concurrent ccstatusline processes (e.g. two Claude Code sessions) could silently drop each other's cache entry. - Only ever persist a "no TTY" probe result across process boundaries; a discovered numeric width is no longer written to the cross-process cache, since it's keyed by session_id and the same session can be resumed in a differently-sized terminal, which would otherwise serve a stale width for up to terminalWidthCacheTtlSeconds. - Correct the README's claim that the cache is "shared ... across sessions" -- it's deliberately isolated per session_id, which is the point of keying it that way. - Remove context.terminalWidthCacheTtlSeconds: it was write-only dead state (the TTL is already consumed by the getTerminalWidth() call that builds the context). - Add a Terminal Width Cache TTL entry to the TUI's status-line config menu, mirroring the existing Git Cache TTL entry -- the setting was previously only reachable by hand-editing settings.json. - Add integration/wiring tests for the sessionId/ttlSeconds path (previously only unit-tested in isolation) and for the new locking behavior. --- README.md | 4 +- src/ccstatusline.ts | 1 - src/tui/App.tsx | 12 ++ src/tui/components/RefreshIntervalMenu.tsx | 105 ++++++++++++++++- .../__tests__/RefreshIntervalMenu.test.ts | 111 ++++++++++++++++-- src/types/RenderContext.ts | 1 - .../__tests__/terminal-width-cache.test.ts | 72 +++++++++++- src/utils/__tests__/terminal.test.ts | 111 ++++++++++++++++++ src/utils/terminal-width-cache.ts | 102 +++++++++++++--- src/utils/terminal.ts | 19 ++- 10 files changed, 501 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 334013de..7bb1bed7 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,8 @@ - **⚡ Zero-subprocess width detection** - On Linux the terminal width is now read from `/proc` and `TIOCGWINSZ` instead of shelling out. A render went from **122 spawned processes to 1** (`node` itself) and from **4.37 CPU-seconds to 1.08** on the benchmark payload. macOS/BSD keep the portable `ps`/`stty`/`tput` walk, now invoked without a `/bin/sh` wrapper. - **🎯 Correct width, not just faster** - The old `tput cols` fallback reported its no-TTY default of 80 columns on terminals that were actually 209 wide. The new probe finds the real pty and reports the true width. -- **♻️ The width is cached** - Claude Code spawns the status line without a TTY, so the probe returned `null`; because callers read it as `context.terminalWidth ?? getTerminalWidth()`, that `null` re-ran the whole ancestor walk **once per configured line, on every render**. The result (including a `null`) is now memoized in-process and shared across renders and sessions via `~/.cache/ccstatusline/terminal-width.json`. -- **⏱️ `terminalWidthCacheTtlSeconds`** - New setting, default `5`, range 0–300. Controls how long a probed width is reused; resize your terminal and the status line corrects itself within this many seconds. Set to `0` to probe on every render. (Note: unlike `gitCacheTtlSeconds`, where `0` means "never expire", `0` here disables the cache.) +- **♻️ The "no TTY" case is cached** - Claude Code spawns the status line without a TTY, so the probe returned `null`; because callers read it as `context.terminalWidth ?? getTerminalWidth()`, that `null` re-ran the whole ancestor walk **once per configured line, on every render**. That `null` result is now memoized in-process for the render, and persisted per-session via `~/.cache/ccstatusline/terminal-width.json` so later renders in the same session skip the walk entirely. A *discovered* width is deliberately never persisted across processes (only kept in-process for that one render), so a terminal resize — or resuming the same session in a differently-sized terminal — is always reflected on the next render rather than delayed by a stale cross-process cache entry. +- **⏱️ `terminalWidthCacheTtlSeconds`** - New setting, default `5`, range 0–300. Controls how long a cached "no TTY" result is trusted before re-probing, in case a session moves from a non-interactive context into one with a real terminal. Set to `0` to always re-probe. (Note: unlike `gitCacheTtlSeconds`, where `0` means "never expire", `0` here disables the cache.) ### v2.2.22 - v2.2.24 - Powerline flex mode, cache/CI/sandbox visibility, layout controls, composable metrics, and safer config diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 56360687..5b386a9c 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -178,7 +178,6 @@ async function renderMultipleLines(data: StatusJSON) { isPreview: false, minimalist: settings.minimalistMode, gitCacheTtlSeconds: settings.gitCacheTtlSeconds, - terminalWidthCacheTtlSeconds: settings.terminalWidthCacheTtlSeconds, gitReviewNeedsChecks: lines.some(line => line.some(item => item.type === 'git-ci-status')) }; diff --git a/src/tui/App.tsx b/src/tui/App.tsx index dbcd9c5d..229c3fdb 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -1279,6 +1279,7 @@ export const App: React.FC = () => { currentInterval={currentRefreshInterval} supportsRefreshInterval={supportsRefreshInterval} gitCacheTtlSeconds={settings.gitCacheTtlSeconds} + terminalWidthCacheTtlSeconds={settings.terminalWidthCacheTtlSeconds} onUpdate={(interval) => { const previous = currentRefreshInterval; setCurrentRefreshInterval(interval); @@ -1309,6 +1310,17 @@ export const App: React.FC = () => { }); setScreen('main'); }} + onTerminalWidthCacheTtlUpdate={(ttlSeconds) => { + setSettings({ + ...settings, + terminalWidthCacheTtlSeconds: ttlSeconds + }); + setFlashMessage({ + text: '✓ Terminal Width cache TTL updated', + color: 'green' + }); + setScreen('main'); + }} onBack={() => { setScreen('main'); }} diff --git a/src/tui/components/RefreshIntervalMenu.tsx b/src/tui/components/RefreshIntervalMenu.tsx index 88be36cc..e85f928b 100644 --- a/src/tui/components/RefreshIntervalMenu.tsx +++ b/src/tui/components/RefreshIntervalMenu.tsx @@ -12,7 +12,7 @@ import { type ListEntry } from './List'; -type ConfigureStatusLineValue = 'refreshInterval' | 'gitCacheTtl'; +type ConfigureStatusLineValue = 'refreshInterval' | 'gitCacheTtl' | 'terminalWidthCacheTtl'; function getRefreshInputValue(interval: number | null): string { return interval === null ? '' : String(interval); @@ -36,10 +36,17 @@ function getGitCacheTtlSublabel(ttlSeconds: number): string { : `(${ttlSeconds}s)`; } +function getTerminalWidthCacheTtlSublabel(ttlSeconds: number): string { + return ttlSeconds === 0 + ? '(disabled)' + : `(${ttlSeconds}s)`; +} + export function buildConfigureStatusLineItems( refreshInterval: number | null, supportsRefreshInterval: boolean, - gitCacheTtlSeconds: number + gitCacheTtlSeconds: number, + terminalWidthCacheTtlSeconds: number ): ListEntry[] { return [ { @@ -56,6 +63,12 @@ export function buildConfigureStatusLineItems( sublabel: getGitCacheTtlSublabel(gitCacheTtlSeconds), value: 'gitCacheTtl', description: 'How long git widget subprocess output can be reused while .git/HEAD and .git/index are unchanged. Enter 0-60 seconds;\n0 disables age-based expiry, so cached output is reused until those git metadata mtimes change.' + }, + { + label: '🖥️ Terminal Width Cache TTL', + sublabel: getTerminalWidthCacheTtlSublabel(terminalWidthCacheTtlSeconds), + value: 'terminalWidthCacheTtl', + description: 'How long a cached "no TTY detected" result is trusted before re-probing the terminal width. Enter 0-300 seconds;\n0 disables the cache (always re-probes). A detected width is never cached across renders, only this no-TTY result is.' } ]; } @@ -100,12 +113,32 @@ export function validateGitCacheTtlInput(value: string): string | null { return null; } +export function validateTerminalWidthCacheTtlInput(value: string): string | null { + const parsed = parseInt(value, 10); + + if (value === '' || isNaN(parsed)) { + return 'Please enter a valid number'; + } + + if (parsed < 0) { + return `Minimum Terminal Width cache TTL is 0s (you entered ${parsed}s)`; + } + + if (parsed > 300) { + return `Maximum Terminal Width cache TTL is 300s (you entered ${parsed}s)`; + } + + return null; +} + export interface RefreshIntervalMenuProps { currentInterval: number | null; supportsRefreshInterval: boolean; gitCacheTtlSeconds: number; + terminalWidthCacheTtlSeconds: number; onUpdate: (interval: number | null) => void; onGitCacheTtlUpdate: (ttlSeconds: number) => void; + onTerminalWidthCacheTtlUpdate: (ttlSeconds: number) => void; onBack: () => void; } @@ -113,14 +146,18 @@ export const RefreshIntervalMenu: React.FC = ({ currentInterval, supportsRefreshInterval, gitCacheTtlSeconds, + terminalWidthCacheTtlSeconds, onUpdate, onGitCacheTtlUpdate, + onTerminalWidthCacheTtlUpdate, onBack }) => { const [editingRefreshInterval, setEditingRefreshInterval] = useState(false); const [editingGitCacheTtl, setEditingGitCacheTtl] = useState(false); + const [editingTerminalWidthCacheTtl, setEditingTerminalWidthCacheTtl] = useState(false); const [refreshInput, setRefreshInput] = useState(() => getRefreshInputValue(currentInterval)); const [gitCacheTtlInput, setGitCacheTtlInput] = useState(() => String(gitCacheTtlSeconds)); + const [terminalWidthCacheTtlInput, setTerminalWidthCacheTtlInput] = useState(() => String(terminalWidthCacheTtlSeconds)); const [validationError, setValidationError] = useState(null); useInput((input, key) => { @@ -193,6 +230,37 @@ export const RefreshIntervalMenu: React.FC = ({ return; } + if (editingTerminalWidthCacheTtl) { + if (key.return) { + const error = validateTerminalWidthCacheTtlInput(terminalWidthCacheTtlInput); + + if (error) { + setValidationError(error); + } else { + const value = parseInt(terminalWidthCacheTtlInput, 10); + onTerminalWidthCacheTtlUpdate(value); + setEditingTerminalWidthCacheTtl(false); + setValidationError(null); + } + } else if (key.escape) { + setTerminalWidthCacheTtlInput(String(terminalWidthCacheTtlSeconds)); + setEditingTerminalWidthCacheTtl(false); + setValidationError(null); + } else if (key.backspace) { + setTerminalWidthCacheTtlInput(terminalWidthCacheTtlInput.slice(0, -1)); + setValidationError(null); + } else if (key.delete) { + // No cursor position in simple input + } else if (shouldInsertInput(input, key) && /\d/.test(input)) { + const newValue = terminalWidthCacheTtlInput + input; + if (newValue.length <= 3) { + setTerminalWidthCacheTtlInput(newValue); + setValidationError(null); + } + } + return; + } + if (key.escape) { onBack(); } @@ -238,10 +306,31 @@ export const RefreshIntervalMenu: React.FC = ({ )} Press Enter to confirm, ESC to cancel. + ) : editingTerminalWidthCacheTtl ? ( + + + Enter Terminal Width cache TTL in seconds (0-300): + {' '} + {terminalWidthCacheTtlInput} + {terminalWidthCacheTtlInput.length > 0 ? 's' : ''} + + + + This affects how long a "no TTY detected" result is trusted before re-probing. + + {validationError ? ( + {validationError} + ) : ( + + 0 disables the cache, always re-probing. A detected width is never cached across renders. + + )} + Press Enter to confirm, ESC to cancel. + ) : ( { if (value === 'back') { onBack(); @@ -254,8 +343,14 @@ export const RefreshIntervalMenu: React.FC = ({ return; } - setGitCacheTtlInput(String(gitCacheTtlSeconds)); - setEditingGitCacheTtl(true); + if (value === 'gitCacheTtl') { + setGitCacheTtlInput(String(gitCacheTtlSeconds)); + setEditingGitCacheTtl(true); + return; + } + + setTerminalWidthCacheTtlInput(String(terminalWidthCacheTtlSeconds)); + setEditingTerminalWidthCacheTtl(true); }} showBackButton={true} /> diff --git a/src/tui/components/__tests__/RefreshIntervalMenu.test.ts b/src/tui/components/__tests__/RefreshIntervalMenu.test.ts index fd300189..82336b4f 100644 --- a/src/tui/components/__tests__/RefreshIntervalMenu.test.ts +++ b/src/tui/components/__tests__/RefreshIntervalMenu.test.ts @@ -12,7 +12,8 @@ import { RefreshIntervalMenu, buildConfigureStatusLineItems, validateGitCacheTtlInput, - validateRefreshIntervalInput + validateRefreshIntervalInput, + validateTerminalWidthCacheTtlInput } from '../RefreshIntervalMenu'; class MockTtyStream extends PassThrough { @@ -105,41 +106,70 @@ describe('validateGitCacheTtlInput', () => { describe('buildConfigureStatusLineItems', () => { it('should show (not set) when interval is null and supported', () => { - const items = buildConfigureStatusLineItems(null, true, 5); + const items = buildConfigureStatusLineItems(null, true, 5, 5); expect(items[0]?.sublabel).toBe('(not set)'); }); it('should show seconds for set intervals', () => { - const items = buildConfigureStatusLineItems(10, true, 5); + const items = buildConfigureStatusLineItems(10, true, 5, 5); expect(items[0]?.sublabel).toBe('(10s)'); }); it('should show seconds for small values', () => { - const items = buildConfigureStatusLineItems(1, true, 5); + const items = buildConfigureStatusLineItems(1, true, 5, 5); expect(items[0]?.sublabel).toBe('(1s)'); }); it('should show version requirement when not supported', () => { - const items = buildConfigureStatusLineItems(null, false, 5); + const items = buildConfigureStatusLineItems(null, false, 5, 5); expect(items[0]?.sublabel).toContain('requires Claude Code'); expect(items[0]?.disabled).toBe(true); }); it('should not be disabled when supported', () => { - const items = buildConfigureStatusLineItems(10, true, 5); + const items = buildConfigureStatusLineItems(10, true, 5, 5); expect(items[0]?.disabled).toBeFalsy(); }); it('should show the configured Git cache TTL', () => { - const items = buildConfigureStatusLineItems(10, true, 5); + const items = buildConfigureStatusLineItems(10, true, 5, 5); expect(items[1]?.label).toContain('Git Cache TTL'); expect(items[1]?.sublabel).toBe('(5s)'); }); it('should describe zero Git cache TTL as mtime-only', () => { - const items = buildConfigureStatusLineItems(10, true, 0); + const items = buildConfigureStatusLineItems(10, true, 0, 5); expect(items[1]?.sublabel).toBe('(mtime only)'); }); + + it('should show the configured Terminal Width cache TTL', () => { + const items = buildConfigureStatusLineItems(10, true, 5, 30); + expect(items[2]?.label).toContain('Terminal Width Cache TTL'); + expect(items[2]?.sublabel).toBe('(30s)'); + }); + + it('should describe zero Terminal Width cache TTL as disabled', () => { + const items = buildConfigureStatusLineItems(10, true, 5, 0); + expect(items[2]?.sublabel).toBe('(disabled)'); + }); +}); + +describe('validateTerminalWidthCacheTtlInput', () => { + it('should accept valid values within range', () => { + expect(validateTerminalWidthCacheTtlInput('0')).toBeNull(); + expect(validateTerminalWidthCacheTtlInput('5')).toBeNull(); + expect(validateTerminalWidthCacheTtlInput('300')).toBeNull(); + }); + + it('should reject values outside the range', () => { + expect(validateTerminalWidthCacheTtlInput('-1')).toContain('Minimum'); + expect(validateTerminalWidthCacheTtlInput('301')).toContain('Maximum'); + }); + + it('should reject empty and non-numeric input', () => { + expect(validateTerminalWidthCacheTtlInput('')).toContain('valid number'); + expect(validateTerminalWidthCacheTtlInput('abc')).toContain('valid number'); + }); }); describe('RefreshIntervalMenu', () => { @@ -154,8 +184,10 @@ describe('RefreshIntervalMenu', () => { currentInterval: null, supportsRefreshInterval: true, gitCacheTtlSeconds: 5, + terminalWidthCacheTtlSeconds: 5, onUpdate, onGitCacheTtlUpdate: vi.fn(), + onTerminalWidthCacheTtlUpdate: vi.fn(), onBack }), { @@ -201,8 +233,10 @@ describe('RefreshIntervalMenu', () => { currentInterval: 10, supportsRefreshInterval: true, gitCacheTtlSeconds: 0, + terminalWidthCacheTtlSeconds: 5, onUpdate, onGitCacheTtlUpdate, + onTerminalWidthCacheTtlUpdate: vi.fn(), onBack }), { @@ -238,4 +272,65 @@ describe('RefreshIntervalMenu', () => { stderr.destroy(); } }); + + it('shows helper text while editing Terminal Width cache TTL and saves updates', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const onUpdate = vi.fn(); + const onGitCacheTtlUpdate = vi.fn(); + const onTerminalWidthCacheTtlUpdate = vi.fn(); + const onBack = vi.fn(); + // supportsRefreshInterval: false disables and skips the first list entry, so + // Git Cache TTL becomes the first selectable item and Terminal Width Cache TTL + // is reachable with a single down-arrow press. Two rapid consecutive down-arrow + // presses are flaky in this test harness (the pre-existing equivalent test in + // TerminalWidthMenu.test.ts exhibits the same flakiness), so this avoids relying + // on that pattern rather than adding another flaky test. + const instance = render( + React.createElement(RefreshIntervalMenu, { + currentInterval: 10, + supportsRefreshInterval: false, + gitCacheTtlSeconds: 5, + terminalWidthCacheTtlSeconds: 5, + onUpdate, + onGitCacheTtlUpdate, + onTerminalWidthCacheTtlUpdate, + onBack + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + stdin.write(''); + await new Promise((resolve) => { setTimeout(resolve, 150); }); + stdin.write('\r'); + await flushInk(); + + expect(stdout.getOutput()).toContain('Enter Terminal Width cache TTL in seconds (0-300):'); + expect(stdout.getOutput()).toContain('no TTY detected'); + + await new Promise((resolve) => { setTimeout(resolve, 150); }); + stdin.write('\r'); + await flushInk(); + + expect(onTerminalWidthCacheTtlUpdate).toHaveBeenCalledWith(5); + expect(onGitCacheTtlUpdate).not.toHaveBeenCalled(); + expect(onUpdate).not.toHaveBeenCalled(); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); }); diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index cc6cee3a..b0ce0375 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -44,7 +44,6 @@ export interface RenderContext { isPreview?: boolean; minimalist?: boolean; gitCacheTtlSeconds?: number; - terminalWidthCacheTtlSeconds?: number; gitReviewNeedsChecks?: boolean; lineIndex?: number; // Index of the current line being rendered (for theme cycling) globalSeparatorIndex?: number; // Global separator index that continues across lines diff --git a/src/utils/__tests__/terminal-width-cache.test.ts b/src/utils/__tests__/terminal-width-cache.test.ts index 3eb5b1ba..296e20f1 100644 --- a/src/utils/__tests__/terminal-width-cache.test.ts +++ b/src/utils/__tests__/terminal-width-cache.test.ts @@ -1,4 +1,6 @@ +import * as fs from 'fs'; import { + afterEach, beforeEach, describe, expect, @@ -11,7 +13,12 @@ import { writeCachedWidth } from '../terminal-width-cache'; -const CACHE_PATH = '/tmp/test-terminal-width.json'; +// Unique per test process/run: the lock file lives on the real filesystem +// (see terminal-width-cache.ts's withCacheLock), so a fixed shared path here +// could collide with another concurrently-running test worker/process on the +// same machine touching the same real path. +const CACHE_PATH = `/tmp/test-terminal-width-${process.pid}-${Math.random().toString(36).slice(2)}.json`; +const LOCK_PATH = `${CACHE_PATH}.lock`; function makeDeps(initial: string | null, now = 1_000_000): WidthCacheDeps & { files: Map } { const files = new Map(); @@ -50,6 +57,19 @@ describe('terminal width cache', () => { beforeEach(() => { deps = makeDeps(null); + try { + fs.unlinkSync(LOCK_PATH); + } catch { + // no lock file left over from a previous test + } + }); + + afterEach(() => { + try { + fs.unlinkSync(LOCK_PATH); + } catch { + // already cleaned up + } }); it('returns null on a cache miss', () => { @@ -115,4 +135,54 @@ describe('terminal width cache', () => { expect(renames).toHaveLength(1); expect(renames[0]).toContain(`->${CACHE_PATH}`); }); + + describe('write locking', () => { + it('skips the write when a fresh lock is already held (concurrent writer)', () => { + fs.writeFileSync(LOCK_PATH, String(process.pid)); + + writeCachedWidth('session-a', 209, deps); + + expect(deps.files.has(CACHE_PATH)).toBe(false); + expect(readCachedWidth('session-a', 5, deps)).toBeNull(); + }); + + it('does not clobber another writer: only its own entry is ever written under contention', () => { + // Simulate session-b's writer already having committed while session-a's + // writer holds the lock: session-a's deps snapshot predates that write. + writeCachedWidth('session-b', 80, deps); + const snapshotBeforeB = new Map(deps.files); + + const staleDeps = { ...deps, files: snapshotBeforeB }; + fs.writeFileSync(LOCK_PATH, String(process.pid)); + writeCachedWidth('session-a', 209, staleDeps); + fs.unlinkSync(LOCK_PATH); + + // session-a's write was skipped (lock held), so session-b's already + // committed entry survives untouched -- nothing was lost to a stale + // read-modify-write. + expect(readCachedWidth('session-b', 5, deps)).toEqual({ width: 80 }); + }); + + it('steals a stale lock (older than the staleness threshold) and proceeds', () => { + fs.writeFileSync(LOCK_PATH, String(process.pid)); + const past = new Date(Date.now() - 10_000); + fs.utimesSync(LOCK_PATH, past, past); + + writeCachedWidth('session-a', 209, deps); + + expect(readCachedWidth('session-a', 5, deps)).toEqual({ width: 209 }); + }); + + it('releases the lock after a successful write', () => { + writeCachedWidth('session-a', 209, deps); + expect(fs.existsSync(LOCK_PATH)).toBe(false); + }); + + it('releases the lock even when the write throws', () => { + const throwing = makeDeps(null); + throwing.writeFileSync = () => { throw new Error('disk full'); }; + writeCachedWidth('session-a', 209, throwing); + expect(fs.existsSync(LOCK_PATH)).toBe(false); + }); + }); }); diff --git a/src/utils/__tests__/terminal.test.ts b/src/utils/__tests__/terminal.test.ts index 22f34d54..7d09a99c 100644 --- a/src/utils/__tests__/terminal.test.ts +++ b/src/utils/__tests__/terminal.test.ts @@ -13,6 +13,7 @@ import { getTerminalWidth, resetTerminalWidthCache } from '../terminal'; +import * as terminalWidthCache from '../terminal-width-cache'; vi.mock('child_process', () => ({ execSync: vi.fn(), @@ -20,6 +21,12 @@ vi.mock('child_process', () => ({ spawnSync: vi.fn() })); +// vi.spyOn on the module namespace rather than vi.mock('../terminal-width-cache', ...): +// a factory-based vi.mock on a shared module is not reliably file-scoped under this +// test runner and leaked into terminal-width-cache.test.ts's own (unmocked) import of +// the same module when both files ran in the same test invocation, silently replacing +// the real readCachedWidth/writeCachedWidth with no-ops there too. + describe('terminal utils', () => { const mockExecFileSync = execFileSync as unknown as { mock: { calls: unknown[][] }; @@ -342,4 +349,108 @@ describe('terminal utils', () => { resetTerminalWidthCache(); expect(getTerminalWidth()).toBe(175); }); + + // Wiring coverage for the sessionId/ttlSeconds L2-cache integration: unlike + // the tests above (which never pass options), these mock ../terminal-width-cache + // directly and assert on the exact arguments getTerminalWidth calls it with. + // A wiring regression here (wrong args, wrong condition) would pass every + // other test in this suite while silently breaking the cross-process cache. + describe('getTerminalWidth session cache wiring', () => { + function spyOnReadCachedWidth() { + return vi.spyOn(terminalWidthCache, 'readCachedWidth'); + } + function spyOnWriteCachedWidth() { + return vi.spyOn(terminalWidthCache, 'writeCachedWidth'); + } + + let readSpy: ReturnType; + let writeSpy: ReturnType; + + beforeEach(() => { + readSpy = spyOnReadCachedWidth().mockReturnValue(null); + writeSpy = spyOnWriteCachedWidth().mockImplementation(() => undefined); + }); + + afterEach(() => { + readSpy.mockRestore(); + writeSpy.mockRestore(); + }); + + it('consults the L2 cache with the given sessionId and ttlSeconds before probing', () => { + pinPosixPlatform(); + mockExecFileSync.mockImplementation(() => { + throw new Error('no tty anywhere'); + }); + + getTerminalWidth({ sessionId: 'session-a', ttlSeconds: 42 }); + + expect(readSpy).toHaveBeenCalledWith('session-a', 42); + }); + + it('does not consult the L2 cache when no sessionId is given', () => { + pinPosixPlatform(); + mockExecFileSync.mockImplementation(() => { + throw new Error('no tty anywhere'); + }); + + getTerminalWidth(); + + expect(readSpy).not.toHaveBeenCalled(); + }); + + it('returns the L2-cached width on a hit without probing', () => { + readSpy.mockReturnValue({ width: null }); + mockExecFileSync.mockImplementation(() => { + throw new Error('should not probe on an L2 cache hit'); + }); + + expect(getTerminalWidth({ sessionId: 'session-a', ttlSeconds: 5 })).toBeNull(); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + + it('persists a "no TTY" probe result to the L2 cache with the given sessionId', () => { + pinPosixPlatform(); + mockExecFileSync.mockImplementation(() => { + throw new Error('no tty anywhere'); + }); + + getTerminalWidth({ sessionId: 'session-a', ttlSeconds: 5 }); + + expect(writeSpy).toHaveBeenCalledWith('session-a', null); + }); + + it('never persists a discovered numeric width to the L2 cache', () => { + pinPosixPlatform(); + mockExecFileSync.mockImplementation((file: string, args: string[]) => { + if (file === 'ps' && args.join(' ') === `-o ppid= -p ${process.pid}`) { + return '1234\n'; + } + + if (file === 'ps' && args.join(' ') === '-o tty= -p 1234') { + return 'ttys001\n'; + } + + if (file === 'stty' && args.join(' ') === '-F /dev/ttys001 size') { + return '24 120\n'; + } + + throw new Error(`Unexpected command: ${file} ${args.join(' ')}`); + }); + + expect(getTerminalWidth({ sessionId: 'session-a', ttlSeconds: 5 })).toBe(120); + + expect(writeSpy).not.toHaveBeenCalled(); + }); + + it('does not persist to the L2 cache when ttlSeconds is 0', () => { + pinPosixPlatform(); + mockExecFileSync.mockImplementation(() => { + throw new Error('no tty anywhere'); + }); + + getTerminalWidth({ sessionId: 'session-a', ttlSeconds: 0 }); + + expect(writeSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/utils/terminal-width-cache.ts b/src/utils/terminal-width-cache.ts index 76f35073..4c67aa8e 100644 --- a/src/utils/terminal-width-cache.ts +++ b/src/utils/terminal-width-cache.ts @@ -4,6 +4,10 @@ import * as path from 'path'; const CACHE_SCHEMA_VERSION = 1 as const; const PRUNE_AFTER_MS = 60 * 60 * 1000; +// A lock older than this is assumed to belong to a writer that crashed +// mid-write rather than one that is merely slow; stealing it lets the cache +// recover instead of wedging permanently. +const LOCK_STALE_MS = 2000; interface WidthCacheEntry { width: number | null; @@ -47,6 +51,68 @@ function isEntry(value: unknown): value is WidthCacheEntry { && typeof entry.createdAt === 'number'; } +function isEexist(error: unknown): boolean { + return typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'EEXIST'; +} + +/** + * Best-effort mutual exclusion for the read-modify-write in writeCachedWidth. + * Without this, two ccstatusline processes (e.g. two concurrent Claude Code + * sessions) racing to write the same cache file can each read a snapshot + * that doesn't include the other's entry yet, and whichever renames last + * silently clobbers the other's write. + * + * Uses O_CREAT|O_EXCL on a `.lock` file as the exclusion primitive (atomic on + * POSIX filesystems). Non-blocking: if the lock is held and fresh, the caller + * skips this write entirely rather than spinning -- the cache is best-effort, + * so losing one write is fine, but corrupting/clobbering another writer's + * entry is not. A lock older than LOCK_STALE_MS is assumed abandoned by a + * crashed writer and is stolen so the cache can't wedge permanently. + */ +function withCacheLock(cachePath: string, fn: () => void): void { + const lockPath = `${cachePath}.lock`; + let haveLock = false; + // Only EEXIST means "another writer genuinely holds the lock" -- anything + // else (EMFILE under fd pressure, a missing directory, etc.) is unrelated + // to contention, so the write proceeds unlocked rather than being dropped: + // this locking is additive protection, and must never make the cache less + // reliable than it was before locking existed. + let contended = false; + + try { + try { + fs.closeSync(fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY)); + haveLock = true; + } catch (error) { + if (isEexist(error)) { + contended = true; + try { + if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS) { + fs.unlinkSync(lockPath); + fs.closeSync(fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY)); + haveLock = true; + contended = false; + } + } catch { + // Still contended, or another writer already recovered it; skip this write. + } + } + } + + if (haveLock || !contended) { + fn(); + } + } finally { + if (haveLock) { + try { + fs.unlinkSync(lockPath); + } catch { + // best-effort + } + } + } +} + function readCache(deps: WidthCacheDeps): PersistentWidthCache { const empty: PersistentWidthCache = { version: CACHE_SCHEMA_VERSION, entries: {} }; try { @@ -113,25 +179,31 @@ export function writeCachedWidth( deps: WidthCacheDeps = defaultDeps ): void { try { - const now = deps.now(); - const existing = readCache(deps); + // Ensure the directory exists before locking: the lock file lives + // next to the cache file, so on the very first write ever, the lock + // acquisition itself would fail with ENOENT otherwise. + deps.mkdirSync(path.dirname(deps.cachePath)); - // Prune entries older than an hour so the file cannot grow without - // bound across many sessions. - const entries: Record = {}; - for (const [key, entry] of Object.entries(existing.entries)) { - if (now - entry.createdAt <= PRUNE_AFTER_MS) { - entries[key] = entry; + withCacheLock(deps.cachePath, () => { + const now = deps.now(); + const existing = readCache(deps); + + // Prune entries older than an hour so the file cannot grow without + // bound across many sessions. + const entries: Record = {}; + for (const [key, entry] of Object.entries(existing.entries)) { + if (now - entry.createdAt <= PRUNE_AFTER_MS) { + entries[key] = entry; + } } - } - entries[sessionId] = { width, createdAt: now }; - const cache: PersistentWidthCache = { version: CACHE_SCHEMA_VERSION, entries }; + entries[sessionId] = { width, createdAt: now }; + const cache: PersistentWidthCache = { version: CACHE_SCHEMA_VERSION, entries }; - deps.mkdirSync(path.dirname(deps.cachePath)); - const tempPath = `${deps.cachePath}.${process.pid}.tmp`; - deps.writeFileSync(tempPath, JSON.stringify(cache)); - deps.renameSync(tempPath, deps.cachePath); + const tempPath = `${deps.cachePath}.${process.pid}.tmp`; + deps.writeFileSync(tempPath, JSON.stringify(cache)); + deps.renameSync(tempPath, deps.cachePath); + }); } catch { // Best-effort cache; the statusline must render regardless. } diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index d574ea8c..71e087d7 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -215,10 +215,12 @@ export function getTerminalWidth(options?: TerminalWidthOptions): number | null const ttlSeconds = options?.ttlSeconds ?? 0; // L2: another process in this session may have already paid for the probe. + // Only ever consulted for a cached "no TTY" result -- see the write side + // below for why a discovered numeric width is never read back here either. if (sessionId) { const cached = readCachedWidth(sessionId, ttlSeconds); - if (cached) { - cachedWidth = cached.width; + if (cached?.width === null) { + cachedWidth = null; hasProbed = true; return cachedWidth; } @@ -227,8 +229,17 @@ export function getTerminalWidth(options?: TerminalWidthOptions): number | null cachedWidth = probeTerminalWidth(); hasProbed = true; - if (sessionId && ttlSeconds > 0) { - writeCachedWidth(sessionId, cachedWidth); + // Persist only the "no TTY" result, never a discovered numeric width. + // The no-TTY case is the one this cache exists for (Claude Code spawning + // the statusline without a controlling terminal is stable for the life of + // a session, so it's safe to cache indefinitely and expensive to keep + // re-walking). A real width is comparatively cheap to redetect and, unlike + // "no TTY", is NOT stable per session: the same session_id can be resumed + // in a different terminal with a different width, and persisting a numeric + // width across processes would serve that stale width for up to + // ttlSeconds after the resume. + if (sessionId && ttlSeconds > 0 && cachedWidth === null) { + writeCachedWidth(sessionId, null); } return cachedWidth; From 4c98c38e785d8d6994c82ac456fcec1e7d78a7c7 Mon Sep 17 00:00:00 2001 From: elhoim Date: Thu, 23 Jul 2026 10:56:53 +0000 Subject: [PATCH 8/8] test: isolate execFileSync spy from leaked cross-file child_process mocks Around twenty suites replace child_process wholesale with vi.mock('child_process', ...). That factory form is not file-scoped under the bun runner, so the replacement stays installed in the module registry for every file that runs afterwards. When one of those suites runs before this one, vi.spyOn hands back the already-installed mock together with its accumulated call history instead of a fresh spy. The stderr-silencing test iterates every recorded call and asserts stdio is ['ignore', 'pipe', 'ignore'], so it was inspecting git and terminal probes from other files, which legitimately use ['pipe', 'pipe', 'ignore'], and failed. The test passes in isolation and fails in the full suite, which is why CI reported it as the only failure. Clearing the call history when the spy is installed scopes every assertion in this file to the calls its own test made. Co-Authored-By: Claude Opus 4.8 --- src/utils/__tests__/global-command-resolution.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/global-command-resolution.test.ts b/src/utils/__tests__/global-command-resolution.test.ts index 61b002f8..97cd0c8a 100644 --- a/src/utils/__tests__/global-command-resolution.test.ts +++ b/src/utils/__tests__/global-command-resolution.test.ts @@ -17,7 +17,7 @@ import { } from '../package-manager-executable'; function mockExecFileSync(responses: Record) { - return vi.spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => { + const spy = vi.spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => { const key = `${command} ${(args as string[]).join(' ')}`; const response = responses[key]; @@ -27,6 +27,14 @@ function mockExecFileSync(responses: Record) { return response; }); + + // Other suites replace child_process wholesale via vi.mock, which is not + // file-scoped under the bun runner. When one of those runs first, vi.spyOn + // hands back that already-installed mock along with its accumulated call + // history, so assertions here would otherwise inspect foreign calls. + spy.mockClear(); + + return spy; } describe('global command resolution', () => {