From c5079fca6952c4273526b09f85cddd4ccee5885a Mon Sep 17 00:00:00 2001 From: axisrow Date: Tue, 29 Sep 2026 21:27:25 +0800 Subject: [PATCH 1/9] feat(daemon): opt-in shared-mode install and richer status UX (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - enableSharedMode/disableSharedMode in claude-settings.ts: switch the Claude Code statusLine to the IPC client wrapper, remember the previous one-shot command in ccstatusline settings, restore it verbatim on return - daemon install / daemon uninstall subcommands drive the switch; the daemon is started only by these explicit commands, never from the render path (the wrapper fails clean with empty stdout when it is down) - daemon status now reports uptime, last render time, active renders and aggregate counters from /v1/health — identity and timings, no secrets - ship client/ccstatusline-ipc in the npm package (files += client/) Part of axisrow/ccstatusline#19 Co-Authored-By: Claude Code --- package.json | 3 +- src/daemon/lifecycle.ts | 108 ++++++++++++++++++++++--- src/types/Settings.ts | 15 +++- src/utils/claude-settings.ts | 148 ++++++++++++++++++++++++++++++++++- 4 files changed, 261 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index a7a3d10f9..f7a29995b 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "./package.json": "./package.json" }, "files": [ - "dist/" + "dist/", + "client/" ], "scripts": { "start": "bun run src/ccstatusline.ts", diff --git a/src/daemon/lifecycle.ts b/src/daemon/lifecycle.ts index 81211cc1b..173fef9bf 100644 --- a/src/daemon/lifecycle.ts +++ b/src/daemon/lifecycle.ts @@ -75,6 +75,13 @@ export interface DaemonHealth { protocol: number; version: string; pid: number; + /** Optional observability fields (#19) — never secrets, safe to print. */ + runtime?: string; + startedAt?: string; + uptimeSeconds?: number; + lastRenderMs?: number | null; + activeRenders?: number; + counters?: Record; } export interface LifecycleOptions { @@ -108,10 +115,21 @@ export type StopOutcome | { state: 'not-running'; detail?: string }; export type StatusOutcome - = | { state: 'running'; pid: number; version: string; protocol: number; startedAt: string | undefined } - | { state: 'stopped' } - | { state: 'stale'; detail: string } - | { state: 'incompatible'; pid: number; version: string; protocol: number; expectedVersion: string }; + = | { + state: 'running'; + pid: number; + version: string; + protocol: number; + startedAt: string | undefined; + runtime?: string; + uptimeSeconds?: number; + lastRenderMs?: number | null; + activeRenders?: number; + counters?: Record; + } + | { state: 'stopped' } + | { state: 'stale'; detail: string } + | { state: 'incompatible'; pid: number; version: string; protocol: number; expectedVersion: string }; const LOCK_FILE_NAME = 'daemon-start.lock'; @@ -611,7 +629,18 @@ export async function daemonStatus(options: LifecycleOptions = {}): Promise | undefined): string { + if (!counters) { + return ''; + } + const order = ['ok', 'deduped', 'render_failed', 'busy', 'unauthorized', 'bad_request']; + const names = [...order, ...Object.keys(counters).filter(name => !order.includes(name))]; + const parts = names.filter(name => counters[name] !== undefined).map(name => `${name}=${counters[name]}`); + return parts.length > 0 ? `, renders ${parts.join(' ')}` : ''; +} + /** - * `ccstatusline daemon [start|stop|status|restart]` (#17). Bare `daemon` - * stays the foreground server host from #16. + * `ccstatusline daemon [start|stop|status|restart|install|uninstall]` (#17, + * #19). Bare `daemon` stays the foreground server host from #16. */ export async function runDaemonCommand(): Promise { const daemonArgIndex = process.argv.indexOf('daemon'); @@ -661,7 +716,10 @@ export async function runDaemonCommand(): Promise { const outcome = await daemonStatus(); if (outcome.state === 'running') { const started = outcome.startedAt ? `, started ${outcome.startedAt}` : ''; - console.log(`daemon running (pid ${outcome.pid}, version ${outcome.version}, protocol ${outcome.protocol}${started})`); + const uptime = outcome.uptimeSeconds !== undefined ? `, uptime ${formatUptime(outcome.uptimeSeconds)}` : ''; + const lastRender = outcome.lastRenderMs !== undefined && outcome.lastRenderMs !== null ? `, last render ${outcome.lastRenderMs}ms` : ''; + const active = outcome.activeRenders !== undefined ? `, active ${outcome.activeRenders}` : ''; + console.log(`daemon running (pid ${outcome.pid}, version ${outcome.version}, protocol ${outcome.protocol}${uptime}${started}${lastRender}${active}${formatCounters(outcome.counters)})`); return; } if (outcome.state === 'stopped') { @@ -680,7 +738,39 @@ export async function runDaemonCommand(): Promise { console.log(`daemon restarted (${stoppedPart}now pid ${outcome.pid}, version ${outcome.version})`); return; } - console.error(`unknown daemon subcommand '${subcommand}'; usage: ccstatusline daemon [start|stop|status|restart]`); + if (subcommand === 'install') { + // Explicit opt-in (#19): switching the status line to the shared + // IPC client happens only here — never from the render path. The + // daemon itself is started on purpose, after the wrapper command + // is recorded. + const { enableSharedMode } = await import('../utils/claude-settings'); + const enabled = await enableSharedMode(); + if (!enabled.statusLineWritten && enabled.reason !== 'shared mode is already active') { + console.error(`ccstatusline daemon install: ${enabled.reason}`); + process.exit(1); + } + const outcome = await ensureDaemon(); + const wrapperPart = enabled.wrapperPath ? `\n statusLine command: sh ${enabled.wrapperPath}` : ''; + console.log(`shared mode on${wrapperPart}\ndaemon ${outcome.state === 'already-running' ? 'already running' : outcome.state} (pid ${outcome.pid}, version ${outcome.version})`); + return; + } + if (subcommand === 'uninstall') { + const { disableSharedMode } = await import('../utils/claude-settings'); + const disabled = await disableSharedMode(); + const stopped = await stopDaemon(); + if (!disabled.statusLineRestored && disabled.reason !== undefined) { + console.log(`shared mode off (nothing to restore: ${disabled.reason})`); + } else { + console.log('shared mode off; one-shot status line restored'); + } + if (stopped.state === 'stopped') { + console.log(`daemon stopped (pid ${stopped.pid})`); + } else if (stopped.detail) { + console.log(`daemon not running (${stopped.detail})`); + } + return; + } + console.error(`unknown daemon subcommand '${subcommand}'; usage: ccstatusline daemon [start|stop|status|restart|install|uninstall]`); process.exit(1); } catch (error) { console.error(`ccstatusline daemon ${subcommand}: ${error instanceof Error ? error.message : String(error)}`); diff --git a/src/types/Settings.ts b/src/types/Settings.ts index 837c2e3c9..812a4d8f3 100644 --- a/src/types/Settings.ts +++ b/src/types/Settings.ts @@ -116,7 +116,20 @@ export const SettingsSchema = z.object({ message: z.string().nullable().optional(), remaining: z.number().nullable().optional() }).optional(), - installation: InstallationMetadataSchema.optional() + installation: InstallationMetadataSchema.optional(), + // Daemon shared mode (#19): present while the Claude Code statusLine + // points at the IPC client wrapper. Remembers the exact previous statusLine + // so returning to one-shot mode restores it verbatim; null previous means + // there was no statusLine and returning removes ours. Additive optional + // key, no version bump needed (same precedent as `theme`). + daemonSharedMode: z.object({ + previousStatusLine: z.object({ + type: z.string(), + command: z.string(), + padding: z.number().optional(), + refreshInterval: z.number().optional() + }).nullable() + }).optional() }); // Inferred type from schema diff --git a/src/utils/claude-settings.ts b/src/utils/claude-settings.ts index cb182eb25..8908995e0 100644 --- a/src/utils/claude-settings.ts +++ b/src/utils/claude-settings.ts @@ -2,6 +2,7 @@ import { execSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { fileURLToPath } from 'url'; import { z } from 'zod'; import type { ClaudeSettings } from '../types/ClaudeSettings'; @@ -15,7 +16,9 @@ import { import { getConfigPath, isCustomConfigPath, - saveInstallationMetadata + loadSettings, + saveInstallationMetadata, + saveSettings } from './config'; // Re-export for backward compatibility @@ -71,7 +74,8 @@ export function isKnownCommand(command: string): boolean { const prefixes = [CCSTATUSLINE_COMMANDS.AUTO_NPX, CCSTATUSLINE_COMMANDS.AUTO_BUNX, CCSTATUSLINE_COMMANDS.GLOBAL]; // Also match local development commands (e.g., "bun run /path/to/ccstatusline.ts") return prefixes.some(prefix => command === prefix || command.startsWith(`${prefix} --config `)) - || /(?:^|[\s"'\\/])ccstatusline\.ts(?=$|[\s"'])/.test(command); + || /(?:^|[\s"'\\/])ccstatusline\.ts(?=$|[\s"'])/.test(command) + || isSharedModeCommand(command); } function needsQuoting(filePath: string): boolean { @@ -384,6 +388,14 @@ export function classifyInstallation( }; } + // Daemon shared mode wrapper (#19): managed by `daemon install/uninstall`. + if (isSharedModeCommand(statusLineCommand)) { + return { + method: 'self-managed', + packageManager: 'unknown' + }; + } + return { method: 'unknown', packageManager: 'unknown' @@ -498,6 +510,138 @@ export async function getExistingStatusLine(): Promise { } } +// --------------------------------------------------------------------------- +// Daemon shared mode (#19): strictly opt-in switch of the Claude Code +// statusLine command to the IPC client wrapper, with a tested return path. +// +// The wrapper (`client/ccstatusline-ipc`, shipped in the npm package) never +// starts a daemon: with the daemon down it fails with empty stdout and the +// status line simply does not render. Starting one is always an explicit +// `ccstatusline daemon start|install` — nothing on the render path spawns it. +// --------------------------------------------------------------------------- + +/** Locate the shipped IPC client wrapper, or null when this install has none. */ +export function getDaemonClientWrapperPath(): string | null { + // Walk up from this module because the bundle layout differs between a + // dev checkout (src/utils/) and the npm package (dist/): the wrapper sits + // in /client/ccstatusline-ipc in both. + let dir = path.dirname(fileURLToPath(import.meta.url)); + for (let depth = 0; depth < 5; depth++) { + const candidate = path.join(dir, 'client', 'ccstatusline-ipc'); + try { + if (fs.statSync(candidate).isFile()) { + return candidate; + } + } catch { + // Keep walking up. + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + return null; +} + +export function buildSharedModeCommand(wrapperPath: string): string { + return `sh ${quotePathIfNeeded(wrapperPath)}`; +} + +export function isSharedModeCommand(command: string): boolean { + const wrapper = getDaemonClientWrapperPath(); + if (wrapper === null) { + return false; + } + return command === buildSharedModeCommand(wrapper); +} + +export interface SharedModeResult { + /** False when nothing was written (already active, refused, unsupported). */ + statusLineWritten: boolean; + wrapperPath?: string; + /** Human-readable reason when statusLineWritten is false. */ + reason?: string; +} + +/** + * Point the Claude Code statusLine at the daemon IPC client wrapper and + * remember the previous statusLine in ccstatusline's own settings so + * `disableSharedMode` can restore it verbatim. Writes Claude settings only + * after the previous command is safely recorded. Idempotent: a repeated + * enable keeps the originally remembered one-shot command. + */ +export async function enableSharedMode(): Promise { + if (process.platform === 'win32') { + return { statusLineWritten: false, reason: 'daemon mode is not supported on Windows (Unix socket transport); the one-shot status line stays in place' }; + } + const wrapperPath = getDaemonClientWrapperPath(); + if (wrapperPath === null) { + return { statusLineWritten: false, reason: 'IPC client wrapper (client/ccstatusline-ipc) not found next to this ccstatusline install' }; + } + if (!isExecutableAvailable('curl')) { + return { statusLineWritten: false, wrapperPath, reason: 'curl is required by the shared-mode client but was not found on PATH' }; + } + + let claude: ClaudeSettings; + try { + claude = await loadClaudeSettings({ logErrors: false }); + } catch { + return { statusLineWritten: false, wrapperPath, reason: 'could not read Claude settings; refusing to modify' }; + } + + const command = buildSharedModeCommand(wrapperPath); + if (claude.statusLine?.command === command) { + return { statusLineWritten: false, wrapperPath, reason: 'shared mode is already active' }; + } + + const configSettings = await loadSettings(); + configSettings.daemonSharedMode = { previousStatusLine: claude.statusLine ?? null }; + await saveSettings(configSettings); + + await backupClaudeSettings('.orig'); + claude.statusLine = { type: 'command', command, padding: 0 }; + await saveClaudeSettings(claude); + return { statusLineWritten: true, wrapperPath }; +} + +export interface SharedModeDisableResult { + /** False when shared mode was not enabled (or Claude settings are unreadable). */ + statusLineRestored: boolean; + reason?: string; +} + +/** + * Return the Claude Code statusLine to the remembered one-shot command + * (or remove it when shared mode replaced an absent statusLine). No-op when + * shared mode was never enabled through this tool. + */ +export async function disableSharedMode(): Promise { + const configSettings = await loadSettings(); + const remembered = configSettings.daemonSharedMode; + if (!remembered) { + return { statusLineRestored: false, reason: 'shared mode was not enabled' }; + } + + let claude: ClaudeSettings; + try { + claude = await loadClaudeSettings({ logErrors: false }); + } catch { + return { statusLineRestored: false, reason: 'could not read Claude settings; refusing to modify' }; + } + + const previous = remembered.previousStatusLine; + if (previous === null) { + delete claude.statusLine; + } else { + claude.statusLine = { ...previous }; + } + const { daemonSharedMode, ...rest } = configSettings; + await saveSettings(rest); + await saveClaudeSettings(claude); + return { statusLineRestored: true }; +} + export async function getRefreshInterval(): Promise { try { const settings = await loadClaudeSettings({ logErrors: false }); From 10f629a38867620b40f408414b6e755024d3bb07 Mon Sep 17 00:00:00 2001 From: axisrow Date: Tue, 29 Sep 2026 22:01:31 +0800 Subject: [PATCH 2/9] test(daemon): opt-in gating and status/recovery coverage; bench + docs (#19) - daemon-shared-mode.test.ts: enable/disable round-trip, previous-command memory, idempotent enable, unreadable-settings refusal, Windows refusal, wrapper resolution and command classification - lifecycle status test: uptime/counters surfaced, no token in the payload - benchmark-render.py: daemon-bench compares one-shot vs shared (cold/warm, 1/10/36/50 sessions, small/large, slow-command), records daemon health counters, and proves recovery: SIGKILL, dead-daemon wave must fail clean (rc!=0, empty stdout), restart, byte-identical re-renders. Aggregate CPU covers daemon + all clients (reaped children) - docs: USAGE.md daemon section, WINDOWS.md one-shot scope note - fix: isExecutableAvailable passes explicit env to execSync (bun ignores runtime process.env mutations otherwise) Part of axisrow/ccstatusline#19 Co-Authored-By: Claude Code --- .gitignore | 2 + docs/USAGE.md | 37 ++ docs/WINDOWS.md | 9 + scripts/benchmark-render.py | 426 +++++++++++++++++- src/daemon/__tests__/lifecycle.test.ts | 9 +- .../__tests__/daemon-shared-mode.test.ts | 179 ++++++++ src/utils/claude-settings.ts | 5 +- 7 files changed, 646 insertions(+), 21 deletions(-) create mode 100644 src/utils/__tests__/daemon-shared-mode.test.ts diff --git a/.gitignore b/.gitignore index ece75eb45..57863614a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store +__pycache__/ +*.pyc diff --git a/docs/USAGE.md b/docs/USAGE.md index 42886fda9..4dc941aec 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -453,3 +453,40 @@ The widget renders nothing when the current session isn't found, so manual separ When terminal width is detected, status lines automatically truncate with ellipsis (`...`) if they exceed the available width, preventing line wrapping. Truncation is ANSI/OSC-aware, so preserved color output and OSC 8 hyperlinks remain well-formed. + +## Daemon (Shared Render Mode) + +macOS/Linux only. An optional background daemon renders status lines for all +Claude Code sessions through one shared process, reached over a private +per-user Unix socket (bearer-token auth, 0700 runtime directory). The daemon +is strictly **opt-in**: nothing on the normal render path ever starts one. + +```bash +ccstatusline daemon install # switch the Claude Code statusLine to the + # shared-mode client and start the daemon +ccstatusline daemon status # identity, uptime, render counters +ccstatusline daemon restart # after upgrading ccstatusline +ccstatusline daemon uninstall # restore the previous one-shot command +ccstatusline daemon stop # stop the daemon (status line goes quiet) +``` + +- `install` remembers the exact previous `statusLine` entry (in + ccstatusline's own settings) so `uninstall` restores it verbatim; if there + was no status line before, `uninstall` removes ours again. The wrapper + command is `sh /client/ccstatusline-ipc`, POSIX `sh` + `curl` + only — no Node on the repaint path. +- If the daemon is down, the client fails with empty stdout and exit 1: the + status line goes quiet, nothing else breaks, and **no daemon is + auto-started**. Start it explicitly with `daemon start` (or `install`). +- `daemon status` prints what `/v1/health` exposes — protocol and build + identity, pid, uptime, last render time, in-flight renders and aggregate + request counters. It never prints the auth token. Exit code is 1 unless a + compatible daemon answers, so scripts can gate on it. +- After upgrading ccstatusline, a running daemon reports as `incompatible`; + `daemon restart` (or any `daemon start`/`install`) performs a verified + upgrade restart. +- With a custom `--config`, start the daemon with the same `--config`; the + client wrapper itself takes no arguments. +- **Windows keeps the one-shot status line** — the Unix-socket transport does + not exist there, and every `daemon` subcommand refuses with an explicit + message (see [WINDOWS.md](WINDOWS.md)). diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md index 4bc807999..a3f3848c8 100644 --- a/docs/WINDOWS.md +++ b/docs/WINDOWS.md @@ -214,3 +214,12 @@ For the best experience, configure Windows Terminal with these recommended setti `ccstatusline` includes Windows-specific runtime behavior: - **UTF-8 piped output fix**: In piped mode, it attempts to set code page `65001` for reliable symbol rendering - **Path compatibility**: Git and CWD widgets handle both `/` and `\` separators + +## Daemon (Shared Render Mode) + +The daemon render mode is **not available on Windows**: its private IPC +transport is a per-user Unix socket. Windows keeps the regular one-shot +status line, and every `ccstatusline daemon` subcommand (including +`daemon install`) refuses with an explicit message instead of writing a +client-wrapper command that could not work. No action is needed to stay on +the one-shot path — it is the only path on Windows. diff --git a/scripts/benchmark-render.py b/scripts/benchmark-render.py index 0dfe9ef4a..b2783533c 100644 --- a/scripts/benchmark-render.py +++ b/scripts/benchmark-render.py @@ -72,6 +72,89 @@ def worker(i): return [once(i) for _ in range(rounds)] COUNT_WARMUP_RENDERS, COUNT_MEASURED = 1, 2 SHIM_BINS = {'git': '/usr/bin/git', 'security': '/usr/bin/security'} LOAD_CHECK_INTERVAL_S = 10 +CLIENT_SCRIPT = pathlib.Path(__file__).resolve().parent.parent / 'client' / 'ccstatusline-ipc' +SHARED_MODES = ('shared-cold', 'shared-warm', 'daemon-recovery') + +# Slow-command scenario config: one custom-command widget sleeping 400ms per +# render (default timeout 1000ms applies). Written into the run HOME when +# --slow-config is passed; exercises per-render subprocess work beyond git. +SLOW_SETTINGS = { + 'version': 4, + 'lines': [[{'id': 'slow-cmd', 'type': 'custom-command', 'commandPath': 'sleep 0.4; echo slow-render'}]], + 'customCommandCacheTtlSeconds': 0, +} + + +def write_slow_config(home): + config_dir = home / '.config' / 'ccstatusline' + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / 'settings.json').write_text(json.dumps(SLOW_SETTINGS)) + + +def read_discovery(discovery_path): + try: + text = discovery_path.read_text() + except OSError: + return None + info = {} + for line in text.splitlines(): + if line.startswith('#') or '=' not in line: + continue + key, _, value = line.partition('=') + info[key] = value + if info.get('socket') and info.get('token'): + return info + return None + + +def daemon_health(info): + try: + out = subprocess.run(['curl', '-q', '-sS', '--fail', '--noproxy', '*', '--max-time', '5', + '--unix-socket', info['socket'], + '-H', 'Authorization: Bearer %s' % info['token'], + 'http://localhost/v1/health'], capture_output=True, text=True) + except subprocess.SubprocessError: + return None + if out.returncode: + return None + try: + return json.loads(out.stdout) + except ValueError: + return None + + +def start_bench_daemon(runtime, entry, env, runtime_dir): + # The daemon is a direct child of this scenario interpreter: reaping it at + # the end folds its CPU into getrusage(RUSAGE_CHILDREN), so the aggregate + # covers daemon + all client (sh+curl) processes. + runtime_dir.mkdir(parents=True, exist_ok=True) + daemon_env = dict(env, CCSTATUSLINE_RUNTIME_DIR=str(runtime_dir)) + started = time.perf_counter() + proc = subprocess.Popen([runtime, entry, 'daemon'], env=daemon_env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + discovery = runtime_dir / 'daemon.env' + deadline = time.time() + 15 + while time.time() < deadline: + if proc.poll() is not None: + raise RuntimeError('daemon exited during startup (rc=%s)' % proc.returncode) + info = read_discovery(discovery) + if info: + health = daemon_health(info) + if health: + return proc, info, health, (time.perf_counter() - started) * 1000 + time.sleep(0.05) + proc.terminate() + proc.wait() + raise RuntimeError('daemon not ready within 15s') + + +def stop_bench_daemon(proc): + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() def machine_context(): try: @@ -187,6 +270,7 @@ def descendants(root_pid): print(json.dumps(result), flush=True) def cmd_run_one(args): + shared = args.mode in SHARED_MODES label = 's%d-%s-%s%s%s' % (args.sessions, args.mode, args.transcript, '-counts' if args.count_pass else '', args.run_suffix) run_dir = ROOT / 'runs' / label if run_dir.exists(): @@ -210,32 +294,76 @@ def cmd_run_one(args): # reality. Fresh HOME per run isolates cold from any earlier run's caches. env = environment(home, width=True) env['CCSL_FORK'] = '1' + if args.slow_config: + write_slow_config(home) shim_info = {'shims': [], 'counts_measured': {}} shims = [] - if args.count_pass: + if args.count_pass and not shared: shim_dir, shims = make_shims(run_dir) env['PATH'] = str(shim_dir) + os.pathsep + env['PATH'] shim_info['shims'] = [name for name, _ in shims] entry = str(pathlib.Path(args.entry).resolve()) - warmups = 0 if args.mode == 'cold' else (COUNT_WARMUP_RENDERS if args.count_pass else WARMUP_RENDERS) - measured = 1 if args.mode == 'cold' else (COUNT_MEASURED if args.count_pass else WARM_MEASURED) + if args.mode == 'cold' or args.mode == 'shared-cold': + warmups, measured = 0, 1 + elif args.mode == 'daemon-recovery': + warmups, measured = 1, 1 + elif args.count_pass: + warmups, measured = COUNT_WARMUP_RENDERS, COUNT_MEASURED + else: + warmups, measured = WARMUP_RENDERS, WARM_MEASURED first_stderr = '' - def once(i): + # Short path on purpose: the daemon socket must fit sun_path (103 bytes on + # macOS), and the run dir under the system temp dir can be deep. + runtime_dir = pathlib.Path('/tmp') / ('ccsl-dbench-%d' % os.getpid()) + if runtime_dir.exists(): + shutil.rmtree(runtime_dir) + client_env = dict(env, CCSTATUSLINE_RUNTIME_DIR=str(runtime_dir)) + client_argv = ['/bin/sh', str(CLIENT_SCRIPT)] + daemon = None + daemon_info = None + + def start_daemon_now(): + nonlocal daemon, daemon_info + daemon, daemon_info, health, startup_ms = start_bench_daemon(args.runtime, entry, env, runtime_dir) + return health, startup_ms + + # Renders go either through a fresh one-shot process or through the shell + # IPC client against the run's daemon. Returns (session, elapsed_ms, hash, + # rc); rc != 0 means the client produced no status line (stdout empty). + def once(i, allow_fail=False): nonlocal first_stderr start = time.perf_counter() - p = subprocess.run([args.runtime, entry], input=payloads[i], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=repo, env=env, timeout=60) + if shared: + p = subprocess.run(client_argv, input=payloads[i], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=repo, env=client_env, timeout=60) + else: + p = subprocess.run([args.runtime, entry], input=payloads[i], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=repo, env=env, timeout=60) elapsed = (time.perf_counter() - start) * 1000 - if p.returncode: + if p.returncode and not allow_fail: raise RuntimeError('render failed rc=%s stderr=%s' % (p.returncode, p.stderr[:500])) if p.stderr and not first_stderr: first_stderr = p.stderr[:500] - return elapsed, hashlib.sha256(p.stdout.encode()).hexdigest() - def wave(n): - def worker(i): return [once(i) for _ in range(n)] + return i, elapsed, hashlib.sha256(p.stdout.encode()).hexdigest(), p.returncode + + def wave(n, allow_fail=False): + def worker(i): + return [once(i, allow_fail) for _ in range(n)] with concurrent.futures.ThreadPoolExecutor(max_workers=args.sessions) as pool: return sum(pool.map(worker, range(args.sessions)), []) + + daemon_startup_ms = None + if shared and args.mode != 'shared-cold': + _, daemon_startup_ms = start_daemon_now() for _ in range(warmups): wave(1) + warmup_hashes = {} + + if args.mode == 'daemon-recovery': + # Warm-wave hashes are the expected output; the kill must not change + # any rendered line after recovery. + for i, _elapsed, digest, rc in wave(1): + if rc == 0: + warmup_hashes.setdefault(i, digest) + counts_before = {name: read_count(f) for name, f in shims} if args.count_pass else {} if args.max_load is not None: # Re-check inside the runner: the orchestrator gate ran seconds ago; @@ -244,9 +372,50 @@ def worker(i): return [once(i) for _ in range(n)] la_start = loadavg1() rbefore = resource.getrusage(resource.RUSAGE_CHILDREN) sampler = subprocess.Popen([sys.executable, __file__, '_sampler', '--target-pid', str(os.getpid()), '--interval-ms', str(args.interval_ms)], stdout=subprocess.PIPE, text=True) + recovery = {} start = time.perf_counter() - samples = wave(measured) - wall = time.perf_counter() - start + if args.mode == 'daemon-recovery': + # Deterministic recovery proof: kill the daemon (SIGKILL, no cleanup), + # then a full wave hits the dead daemon — every client must fail with + # rc!=0 and EMPTY stdout (a partial line must never become a status + # line). Restart, re-render every session, require byte-identical + # output to the warm wave. + assert daemon is not None + daemon.kill() + killed_at = time.perf_counter() + daemon.wait() # reap the killed daemon so its CPU stays in the run + dead_samples = wave(1, allow_fail=True) + survived = [s for s in dead_samples if s[3] == 0] + failed = sorted({i for (i, _e, _h, rc) in dead_samples if rc != 0}) + empty_stdout = all(s[2] == hashlib.sha256(b'').hexdigest() for s in dead_samples if s[3] != 0) + restart_started = time.perf_counter() + _health, _ms = start_daemon_now() + ready_at = time.perf_counter() + reverified = {} + for i in failed: + _i, _e, digest, rc = once(i) + if rc != 0: + raise RuntimeError('recovery re-render failed for session %d' % i) + reverified[i] = digest + mismatched = [i for i, digest in reverified.items() if warmup_hashes.get(i) not in (None, digest)] + recovery = { + 'clients_failed_on_dead_daemon': len(failed), + 'clients_that_still_succeeded': len(survived), + 'failed_clients_stdout_empty': empty_stdout, + 'restart_ready_ms': round((ready_at - restart_started) * 1000, 1), + 'reverified': len(reverified), + 'output_mismatches': mismatched, + } + samples = dead_samples + wall = time.perf_counter() - start + else: + if args.mode == 'shared-cold': + # Cold start: the measured wave includes booting the daemon from + # nothing (no process, cold caches), like the first repaints after + # a manual `daemon start`. + _health, daemon_startup_ms = start_daemon_now() + samples = wave(measured) + wall = time.perf_counter() - start la_end = loadavg1() rafter = resource.getrusage(resource.RUSAGE_CHILDREN) sampler.terminate() @@ -255,7 +424,24 @@ def worker(i): return [once(i) for _ in range(n)] except Exception: rss = {'samples': 0} counts_after = {name: read_count(f) for name, f in shims} if args.count_pass else {} - latencies = sorted(s[0] for s in samples) + if daemon is not None: + # Health snapshot after the measured phase: aggregate counters + # (dedup joins, failures), last render time, in-flight renders. + health = daemon_health(daemon_info) if daemon_info else None + daemon_snapshot = None + if health: + daemon_snapshot = { + 'counters': health.get('counters'), + 'uptime_s': health.get('uptimeSeconds'), + 'last_render_ms': health.get('lastRenderMs'), + 'active_renders': health.get('activeRenders'), + 'startup_ms': round(daemon_startup_ms, 1) if daemon_startup_ms is not None else None, + } + stop_bench_daemon(daemon) # reap: CPU folds into RUSAGE_CHILDREN + shutil.rmtree(runtime_dir, ignore_errors=True) + else: + daemon_snapshot = None + latencies = sorted(s[1] for s in samples) cpu_user = rafter.ru_utime - rbefore.ru_utime cpu_sys = rafter.ru_stime - rbefore.ru_stime cpu_total = cpu_user + cpu_sys @@ -265,6 +451,7 @@ def worker(i): return [once(i) for _ in range(n)] 'transcript_bytes': fixture.stat().st_size, 'count_pass': args.count_pass, 'renders': len(samples), 'warmup_renders': warmups * args.sessions, 'width_override': True, 'ccsl_fork': True, + 'slow_config': args.slow_config, 'cpu_user_s': round(cpu_user, 4), 'cpu_sys_s': round(cpu_sys, 4), 'cpu_total_s': round(cpu_total, 4), 'cpu_per_render_ms': round(cpu_total * 1000 / len(samples), 3), 'cpu_s_per_wall_minute': round(cpu_total / wall * 60, 3), @@ -276,13 +463,17 @@ def worker(i): return [once(i) for _ in range(n)] 'rss_sampling': rss, 'latency_ms': {'n': len(latencies), 'mean': round(statistics.mean(latencies), 2), 'p50': round(pctl(latencies, 0.5), 2), 'p95': round(pctl(latencies, 0.95), 2), 'p99': round(pctl(latencies, 0.99), 2)}, 'latencies_ms': latencies, - 'distinct_output_hashes': len(set(s[1] for s in samples)), + 'output_hashes': sorted(set(s[2] for s in samples if s[3] == 0)), + 'distinct_output_hashes': len(set(s[2] for s in samples if s[3] == 0)), 'spawn_counts_measured': {name: counts_after.get(name, 0) - counts_before.get(name, 0) for name in counts_after}, 'spawn_counts_per_render': {name: round((counts_after.get(name, 0) - counts_before.get(name, 0)) / len(samples), 2) for name in counts_after}, 'first_stderr': first_stderr, 'loadavg_start': la_start, 'loadavg_end': la_end, 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S%z'), } + if shared: + result['daemon'] = daemon_snapshot + result['recovery'] = recovery or None shim_info['counts_measured'] = result['spawn_counts_measured'] result['shims'] = shim_info (ROOT / 'results' / f'{label}.json').write_text(json.dumps(result, indent=2)) @@ -299,7 +490,7 @@ def wait_for_load(max_load, deadline_s): print('[baseline] loadavg %.1f > %.1f, waiting %ds...' % (la, max_load, LOAD_CHECK_INTERVAL_S), flush=True) time.sleep(LOAD_CHECK_INTERVAL_S) -def run_scenario(env, args, sessions, mode, transcript, count_pass, suffix): +def run_scenario(env, args, sessions, mode, transcript, count_pass, suffix, slow=False, tag='daemon-bench'): label = 's%d-%s-%s%s%s' % (sessions, mode, transcript, '-counts' if count_pass else '', suffix) cmd = [sys.executable, str(pathlib.Path(__file__).resolve()), 'run-one', '--runtime', args.runtime, '--entry', args.entry, @@ -307,6 +498,8 @@ def run_scenario(env, args, sessions, mode, transcript, count_pass, suffix): '--interval-ms', str(args.interval_ms)] if count_pass: cmd.append('--count-pass') + if slow: + cmd.append('--slow-config') if suffix: cmd.append('--run-suffix=%s' % suffix) if args.max_load is not None: @@ -315,14 +508,196 @@ def run_scenario(env, args, sessions, mode, transcript, count_pass, suffix): if proc.returncode: raise RuntimeError(proc.stderr.strip()[-800:]) result = json.loads(proc.stdout.strip().splitlines()[-1]) - print('[baseline] %-18s cpu/render=%sms cpu/wallmin=%ss peakRSS=%sMB aggRSS=%sMB p95=%sms%s' % ( - label, result['cpu_per_render_ms'], result['cpu_s_per_wall_minute'], + print('[%s] %-26s cpu/render=%sms cpu/wallmin=%ss peakRSS=%sMB aggRSS=%sMB p50=%sms p95=%sms p99=%sms hashes=%s%s' % ( + tag, label, result['cpu_per_render_ms'], result['cpu_s_per_wall_minute'], round(result['peak_rss_kb'] / 1024, 1), '%s→%s' % (round(result['rss_sampling'].get('aggregate_mean_kb', 0) / 1024, 1), round(result['rss_sampling'].get('aggregate_max_kb', 0) / 1024, 1)), - result['latency_ms']['p95'], - (' git/render=%s' % result['spawn_counts_per_render'].get('git')) if result['spawn_counts_per_render'] else ''), flush=True) + result['latency_ms']['p50'], result['latency_ms']['p95'], result['latency_ms']['p99'], + result.get('distinct_output_hashes'), + (' daemon=%s' % result['daemon']['counters']) if result.get('daemon') and result['daemon'].get('counters') else ''), flush=True) return result + +def cpu_reduction_pct(oneshot, shared): + if not oneshot or not shared or oneshot.get('error') or shared.get('error'): + return None + base = oneshot['cpu_total_s'] + if base <= 0: + return None + return round((base - shared['cpu_total_s']) / base * 100, 1) + + +def cmd_daemon_bench(args): + # One-shot vs shared-mode comparison for the daemon epic (#19): same + # fixtures, same wave pattern; the shared run's aggregate CPU covers the + # daemon plus every client (sh+curl) because both are reaped children of + # the scenario interpreter. + out_dir = pathlib.Path(args.out).resolve() + out_dir.mkdir(parents=True, exist_ok=True) + fixture_root = out_dir / 'fixture-root' + fixture_root.mkdir(parents=True, exist_ok=True) + env = os.environ.copy() + env['CCSTATUSLINE_BENCH_DIR'] = str(fixture_root) + max_load = args.max_load if args.max_load is not None else (os.cpu_count() or 4) / 2 + sessions = [int(s) for s in args.sessions.split(',')] + big = max(sessions) + la_start = wait_for_load(max_load, args.load_wait_sec) + + plan = [] # (kind, sessions, mode, transcript, suffix) + for n in sessions: + plan += [ + ('one-shot', n, 'cold', 'small', ''), + ('one-shot', n, 'warm', 'small', ''), + ('shared', n, 'shared-cold', 'small', ''), + ('shared', n, 'shared-warm', 'small', ''), + ] + plan += [ + ('one-shot', big, 'warm', 'large', '-large'), + ('shared', big, 'shared-warm', 'large', '-large'), + ('one-shot', big, 'warm', 'small', '-slow'), + ('shared', big, 'shared-warm', 'small', '-slow'), + ('recovery', big, 'daemon-recovery', 'small', ''), + ] + + runs = [] + for kind, n, mode, transcript, suffix in plan: + try: + wait_for_load(max_load, args.load_wait_sec) + if kind == 'recovery': + runs.append(run_scenario(env, args, n, mode, transcript, False, suffix)) + else: + slow = suffix == '-slow' + runs.append(run_scenario(env, args, n, mode, transcript, False, suffix, slow=slow)) + except Exception as e: + print('[daemon-bench] FAILED s%d-%s-%s%s: %s' % (n, mode, transcript, suffix, e), flush=True) + runs.append({'label': 's%d-%s-%s%s' % (n, mode, transcript, suffix), 'mode': mode, 'kind': kind, 'error': str(e)}) + + def find(label): + for r in runs: + if r['label'] == label: + return r + return None + + # Pairwise comparison: output equality and aggregate CPU. + pair_specs = [] + for n in sessions: + pair_specs += [(n, 'cold', 'small', ''), (n, 'warm', 'small', '')] + pair_specs += [(big, 'warm', 'large', '-large'), (big, 'warm', 'small', '-slow')] + comparisons = [] + for n, phase, transcript, suffix in pair_specs: + one = find('s%d-%s-%s%s' % (n, phase, transcript, suffix)) + shared = find('s%d-shared-%s-%s%s' % (n, phase, transcript, suffix)) + comparisons.append({ + 'sessions': n, 'workload': transcript, 'phase': phase, + 'oneshot_label': one and one.get('label'), 'shared_label': shared and shared.get('label'), + 'oneshot_cpu_total_s': one and one.get('cpu_total_s'), 'shared_cpu_total_s': shared and shared.get('cpu_total_s'), + 'oneshot_agg_rss_max_kb': one and one.get('rss_sampling', {}).get('aggregate_max_kb'), + 'shared_agg_rss_max_kb': shared and shared.get('rss_sampling', {}).get('aggregate_max_kb'), + 'oneshot_p95_ms': one and one.get('latency_ms', {}).get('p95'), + 'shared_p95_ms': shared and shared.get('latency_ms', {}).get('p95'), + 'output_hashes_equal': (one is not None and shared is not None and not one.get('error') and not shared.get('error') + and sorted(one.get('output_hashes', [])) == sorted(shared.get('output_hashes', []))), + 'cpu_reduction_pct': cpu_reduction_pct(one, shared), + }) + # The epic's gate is defined on the warmed 36-session workload; fall back + # to the largest configured session count only when 36 was not measured. + gate_n = 36 if 36 in sessions else big + gate_one = find('s%d-warm-small' % gate_n) + gate_shared = find('s%d-shared-warm-small' % gate_n) + gate = { + 'scenario': 's%d-warm-small (warmed %d-session workload)' % (gate_n, gate_n), + 'target': '>=50% aggregate-CPU reduction, shared vs one-shot, daemon and clients included', + 'oneshot_cpu_total_s': gate_one and gate_one.get('cpu_total_s'), + 'shared_cpu_total_s': gate_shared and gate_shared.get('cpu_total_s'), + 'reduction_pct': cpu_reduction_pct(gate_one, gate_shared), + 'passed': (cpu_reduction_pct(gate_one, gate_shared) or -999) >= 50, + } + recovery = find('s%d-daemon-recovery-small' % big) + + meta = { + 'runtime': args.runtime, 'entry': str(pathlib.Path(args.entry).resolve()), 'ccsl_fork': True, + 'machine': machine_context(), 'generated': time.strftime('%Y-%m-%dT%H:%M:%S%z'), + 'loadavg_start': la_start, 'loadavg_end': loadavg1(), 'max_load_gate': max_load, + 'notes': [ + 'Measured numbers only; no extrapolation.', + 'Shared-mode aggregate CPU covers the daemon AND every client process (sh + curl): both are reaped children of the scenario interpreter, accounted via getrusage(RUSAGE_CHILDREN).', + 'Cold shared mode includes booting the daemon (no process, cold caches) inside the measured window.', + 'Idle-timer refreshes are not modeled as a separate phase; warm runs measure steady back-to-back repaint waves, and the daemon idle sweep runs only after 5 quiet minutes.', + 'Slow-command scenario: one custom-command widget sleeping 400ms per render, cache TTL 0.', + 'Recovery scenario: SIGKILL of the daemon (no cleanup), a full client wave against the dead daemon must fail with rc!=0 and empty stdout (never a partial line), then the daemon is restarted and every session re-rendered with byte-identical output.', + 'Aggregate RSS is sampled via ps over live process trees (~%sms cadence); short-lived children can be missed, so it undercounts.' % args.interval_ms, + 'Runs were rejected/waited while system loadavg1 exceeded %s (gate recorded above); concurrent sibling sessions still add noise.' % max_load, + ], + } + doc = {'meta': meta, 'runs': runs, 'comparisons': comparisons, 'gate': gate, 'recovery': recovery} + (out_dir / 'daemon-bench.json').write_text(json.dumps(doc, indent=2)) + write_daemon_summary(out_dir, doc) + failed = sum(1 for r in runs if r.get('error')) + print('[daemon-bench] done: %d runs, %d failed. Gate (%s): %s%% reduction, passed=%s. Results: %s/daemon-bench.json, %s/DAEMON-BENCH.md' % ( + len(runs), failed, gate['scenario'], gate['reduction_pct'], gate['passed'], out_dir, out_dir), flush=True) + return 1 if failed or not gate['passed'] else 0 + + +def write_daemon_summary(out_dir, doc): + meta, comparisons, gate, recovery = doc['meta'], doc['comparisons'], doc['gate'], doc['recovery'] + lines = ['# ccstatusline daemon vs one-shot (issue #19)', '', + 'One-shot vs shared-mode (daemon) aggregate cost. Shared numbers cover the', + 'daemon plus every client process; one-shot covers the fresh process per', + 'render and its children (git etc.).', '', + '- runtime: `%s %s`' % (meta['runtime'], meta['entry']), + '- machine: %s, %s cores, %s' % (meta['machine']['cpu_model'], meta['machine']['cpu_count'], meta['machine']['os']), + '- generated: %s, loadavg before/after: %s/%s' % (meta['generated'], meta['loadavg_start'], meta['loadavg_end']), ''] + + lines.append('## Per-scenario aggregate cost') + lines.append('') + lines.append('| scenario | renders | CPU s total | CPU s/render | CPU s/wall-min | agg RSS MB mean→max | p50 ms | p95 ms | p99 ms | distinct hashes |') + lines.append('|---|---|---|---|---|---|---|---|---|---|') + for r in doc['runs']: + if r.get('error'): + lines.append('| %s | FAILED | %s |' % (r['label'], r['error'][:120].replace('|', '\\|'))) + continue + lat = r['latency_ms'] + lines.append('| %s | %d | %s | %s | %s | %s→%s | %s | %s | %s | %s |' % ( + r['label'], r['renders'], r['cpu_total_s'], r['cpu_per_render_ms'], r['cpu_s_per_wall_minute'], + fmt_mb(r['rss_sampling'].get('aggregate_mean_kb', 0)), fmt_mb(r['rss_sampling'].get('aggregate_max_kb', 0)), + lat['p50'], lat['p95'], lat['p99'], r.get('distinct_output_hashes'))) + lines.append('') + + lines.append('## One-shot vs shared comparison') + lines.append('') + lines.append('| sessions | workload | phase | one-shot CPU s | shared CPU s | reduction | hashes equal |') + lines.append('|---|---|---|---|---|---|---|') + for c in comparisons: + lines.append('| %s | %s | %s | %s | %s | %s%% | %s |' % ( + c['sessions'], c['workload'], c['phase'], c['oneshot_cpu_total_s'], c['shared_cpu_total_s'], + c['cpu_reduction_pct'], c['output_hashes_equal'])) + lines.append('') + + lines.append('## Acceptance gate') + lines.append('') + lines.append('- scenario: %s' % gate['scenario']) + lines.append('- target: %s' % gate['target']) + lines.append('- measured: one-shot %s s CPU, shared %s s CPU → %s%% reduction' % (gate['oneshot_cpu_total_s'], gate['shared_cpu_total_s'], gate['reduction_pct'])) + lines.append('- result: %s (measured, not assumed)' % ('PASS' if gate['passed'] else 'FAIL')) + lines.append('') + + if recovery: + rec = recovery.get('recovery') or {} + lines.append('## Recovery (SIGKILL, dead-daemon wave, restart)') + lines.append('') + lines.append('- clients that failed on the dead daemon with rc!=0: %s of %s' % (rec.get('clients_failed_on_dead_daemon'), recovery.get('sessions'))) + lines.append('- clients that still succeeded (should be 0): %s' % rec.get('clients_that_still_succeeded')) + lines.append('- failed clients left stdout empty (no partial status line): %s' % rec.get('failed_clients_stdout_empty')) + lines.append('- daemon restart to ready: %s ms' % rec.get('restart_ready_ms')) + lines.append('- sessions re-rendered after restart: %s, output mismatches vs warm wave: %s' % (rec.get('reverified'), rec.get('output_mismatches'))) + lines.append('') + + lines.append('## Notes and honesty') + lines.append('') + for note in meta['notes']: + lines.append('- %s' % note) + (out_dir / 'DAEMON-BENCH.md').write_text('\n'.join(lines) + '\n') + def fmt_mb(kb): return round(kb / 1024, 1) @@ -481,14 +856,26 @@ def main(): p.add_argument('--interval-ms', type=int, default=25) args = p.parse_args(sys.argv[2:]) sys.exit(cmd_baseline(args)) + if len(sys.argv) > 1 and sys.argv[1] == 'daemon-bench': + p = argparse.ArgumentParser(prog='benchmark-render.py daemon-bench') + p.add_argument('--runtime', required=True) + p.add_argument('--entry', required=True) + p.add_argument('--out', required=True) + p.add_argument('--sessions', default='1,10,36,50') + p.add_argument('--max-load', type=float, default=None, help='loadavg1 gate, default cpu_count/2') + p.add_argument('--load-wait-sec', type=int, default=1800) + p.add_argument('--interval-ms', type=int, default=25) + args = p.parse_args(sys.argv[2:]) + sys.exit(cmd_daemon_bench(args)) if len(sys.argv) > 1 and sys.argv[1] == 'run-one': p = argparse.ArgumentParser(prog='benchmark-render.py run-one') p.add_argument('--runtime', required=True) p.add_argument('--entry', required=True) p.add_argument('--sessions', type=int, required=True) - p.add_argument('--mode', choices=['cold', 'warm'], required=True) + p.add_argument('--mode', choices=['cold', 'warm', 'shared-cold', 'shared-warm', 'daemon-recovery'], required=True) p.add_argument('--transcript', choices=sorted(FIXTURE_ROWS), required=True) p.add_argument('--count-pass', action='store_true') + p.add_argument('--slow-config', action='store_true') p.add_argument('--run-suffix', default='') p.add_argument('--interval-ms', type=int, default=25) p.add_argument('--max-load', type=float, default=None) @@ -509,6 +896,7 @@ def main(): return print('usage: benchmark-render.py