Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 65 additions & 13 deletions client/ccstatusline-ipc
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
# CCSTATUSLINE_RUNTIME_DIR override the daemon runtime directory
# CCSTATUSLINE_IPC_CONNECT_TIMEOUT seconds to connect (default 2)
# CCSTATUSLINE_IPC_TIMEOUT total seconds (default 10)
# CCSTATUSLINE_DAEMON_START override the lazy-start command (see
# start_daemon below)
# CCSTATUSLINE_NO_AUTOSTART when set, never start a daemon lazily
#
# On-demand lifecycle (#53): this wrapper runs only in installed shared mode
# (`daemon install`), so its lazy start is opt-in by construction — one-shot
# users never execute this file. On a missing daemon it starts one (`daemon
# start`, serialized by the #47 cold-start lock) and retries into it.
#
# Exit status: 0 on a rendered status line, 1 on any failure (stdout empty).

Expand All @@ -38,21 +46,57 @@ else
fi
discovery=$runtime_dir/daemon.env

[ -r "$discovery" ] || die "no readable discovery file at $discovery; is the daemon running?"

# Endpoint discovery: flat KEY=VALUE lines written by the daemon. Prefix
# matching keeps values (socket paths may contain '=') intact.
protocol=''
socket=''
token=''
while IFS= read -r line || [ -n "$line" ]; do
case $line in
'#'*) continue ;;
protocol=*) protocol=${line#protocol=} ;;
socket=*) socket=${line#socket=} ;;
token=*) token=${line#token=} ;;
esac
done < "$discovery"
read_discovery() {
protocol=''
socket=''
token=''
[ -r "$discovery" ] || return 1
while IFS= read -r line || [ -n "$line" ]; do
case $line in
'#'*) continue ;;
protocol=*) protocol=${line#protocol=} ;;
socket=*) socket=${line#socket=} ;;
token=*) token=${line#token=} ;;
esac
done < "$discovery"
return 0
}

# Lazy start (#53): run `ccstatusline daemon start` — the #47 cold-start lock
# serializes concurrent starters onto one server, so racing clients each run
# this and converge on the same daemon. Tried at most once per invocation,
# bounded by the starter's own startup timeout. Opt out with
# CCSTATUSLINE_NO_AUTOSTART; override the command with
# CCSTATUSLINE_DAEMON_START (a full command line, split on whitespace).
autostart_attempted=0
start_daemon() {
[ -z "${CCSTATUSLINE_NO_AUTOSTART:-}" ] || return 1
[ "$autostart_attempted" -eq 0 ] || return 1
autostart_attempted=1
if [ -n "${CCSTATUSLINE_DAEMON_START:-}" ]; then
$CCSTATUSLINE_DAEMON_START >/dev/null 2>&1
return $?
fi
# Same install root as this wrapper: the npm package ships
# client/ccstatusline-ipc next to dist/ccstatusline.js (node), a dev
# checkout next to src/ccstatusline.ts (bun).
install_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) || return 1
if [ -f "$install_root/dist/ccstatusline.js" ]; then
node "$install_root/dist/ccstatusline.js" daemon start >/dev/null 2>&1
elif [ -f "$install_root/src/ccstatusline.ts" ]; then
bun "$install_root/src/ccstatusline.ts" daemon start >/dev/null 2>&1
else
return 1
fi
}

if ! read_discovery; then
# No live daemon: start one on demand and re-read what it published.
start_daemon || die "no readable discovery file at $discovery; is the daemon running?"
read_discovery || die "lazy daemon start left no discovery file at $discovery"
fi

[ "$protocol" = "1" ] || die "unsupported daemon protocol '${protocol:-none}'; want 1"
[ -n "$socket" ] || die "discovery file has no socket path"
Expand Down Expand Up @@ -114,6 +158,14 @@ CONFIG
)
curl_rc=$?
[ "$curl_rc" -eq 0 ] && break
# Connect failure (curl exit 7): the daemon died after discovery was
# read. Transparently lazy-start a fresh one and retry into it — the
# autostart guard inside start_daemon bounds this to one restart.
if [ "$curl_rc" -eq 7 ]; then
if start_daemon && read_discovery && [ "$protocol" = "1" ]; then
continue
fi
fi
attempt=$((attempt + 1))
# 503 busy: the render queue is momentarily full (a burst of repaints
# across sessions). Back off on a growing ladder — 8s total budget — so
Expand Down
13 changes: 10 additions & 3 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,9 +475,16 @@ ccstatusline daemon stop # stop the daemon (status line goes quiet)
was no status line before, `uninstall` removes ours again. The wrapper
command is `sh <install>/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`).
- The lifecycle is on-demand (#53): with the daemon down, the shared-mode
client lazily starts one (`ccstatusline daemon start`, serialized by the
cold-start lock) and transparently retries the render into it; after
`daemonIdleStopMinutes` (settings.json, default 10, `0` disables) with zero
requests the daemon exits by itself. The first render after an idle period
pays the cold start; busy periods keep it alive. Set
`CCSTATUSLINE_NO_AUTOSTART=1` to make the client fail fast instead of
starting a daemon, or `CCSTATUSLINE_DAEMON_START="<command>"` to override
the lazy-start command — with autostart disabled that failure is empty
stdout and exit 1: the status line goes quiet, nothing else breaks.
- `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
Expand Down
84 changes: 84 additions & 0 deletions docs/daemon-53-bench.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# ccstatusline on-demand lifecycle bench (issue #53)

Lazy start + idle auto-stop under a 20–30 concurrent-session burst, versus the
same burst rendered one-shot. Companion to [daemon-19-bench.md](daemon-19-bench.md);
raw numbers in [daemon-53-results.json](daemon-53-results.json), harness in
`scripts/benchmark-lifecycle-53.py`.

- runtime: `bun src/ccstatusline.ts` (dev checkout; the wrapper's lazy start
resolves the entry from its own install root)
- machine: Apple M5, 10 cores, macOS-26.6.2-arm64-arm-64bit
- generated: 2026-10-01T14:08:47+0800

## Workload

Each scenario runs N concurrent clients over two waves. Sessions alternate
between a 500-row and a 10 000-row transcript and between a dirty and a clean
git repo, so the daemon's caches hold a cold/warm mix (both transcript sizes
parse; the two repos render distinct git-changes lines). The lazy scenarios
start with **no daemon process at all** — the first wave includes the
on-demand start raced by all N clients.

## Per-scenario cost

| scenario | renders | CPU s/render | p50 ms | p95 ms | p99 ms | failures |
|---|---|---|---|---|---|---|
| oneshot-20-cold | 20 | 188.0 | 434 | 481 | 481 | 0 |
| oneshot-20-warm | 20 | 182.1 | 415 | 451 | 453 | 0 |
| lazy-20-cold (incl. daemon boot) | 20 | 237.0 | 799 | 902 | 905 | 0 |
| lazy-20-warm | 20 | 42.4 | 151 | 271 | 276 | 0 |
| oneshot-30-cold | 30 | 186.4 | 930 | 1035 | 1040 | 0 |
| oneshot-30-warm | 30 | 194.1 | 808 | 934 | 950 | 0 |
| lazy-30-cold (incl. daemon boot) | 30 | 229.7 | 1023 | 1149 | 1202 | 0 |
| lazy-30-warm | 30 | 49.4 | 185 | 481 | 528 | 0 |

## One-shot vs shared (lazy)

| sessions | phase | one-shot CPU s/render | shared CPU s/render | CPU reduction | one-shot p95 | shared p95 | hashes equal |
|---|---|---|---|---|---|---|---|
| 20 | cold | 188.0 ms | 237.0 ms | −26.0% | 481 ms | 902 ms | yes |
| 20 | warm | 182.1 ms | 42.4 ms | **76.7%** | 451 ms | 271 ms | yes |
| 30 | cold | 186.4 ms | 229.7 ms | −23.2% | 1035 ms | 1149 ms | yes |
| 30 | warm | 194.1 ms | 49.4 ms | **74.5%** | 934 ms | 481 ms | yes |

The cold-wave regression is the on-demand start itself, paid once per idle
period: every client that finds no discovery runs `daemon start`, so a burst of
N simultaneous first renders spawns N short-lived starters (one wins the #47
cold-start lock, the rest converge on its server). Warm bursts — the steady
state while sessions are open — keep the 75–80% CPU reduction of #19/#50 and
cut p95 roughly in half.

## Burst behavior (acceptance: no 503 storm)

- lazy-30 cold wave: all 30 clients raced the lazy start; the discovery pid was
identical before and after both waves — exactly one daemon served the whole
scenario (no second server, no restart).
- Daemon counters, lazy-30: `requests=176, ok=91, busy=85`, client failures 0.
The bounded queue (`MAX_IN_FLIGHT_RENDERS = 4`) rejected concurrent cold
renders with 503s exactly as designed, and the client's retry ladder
(0.05 s → 1 s, 8 s budget) absorbed all of them.
- Note from an earlier (unrecorded) run: under the cold 30-burst the
dirty-repo renders once collapsed to the clean-repo line (git reads failing
under load); the recorded run shows the expected two distinct lines with
identical output between one-shot and shared. Watch for it when re-running.

## Idle auto-stop (acceptance: daemon disappears, render restarts it)

With `daemonIdleStopMinutes: 1` and zero requests:

- the daemon exited by itself after 72.2 s observed (1 minute configured; the
idle check runs at `idle/5` cadence, here 12 s, so up to one interval of
lateness),
- its discovery file was cleaned up (no stale state for the next start),
- the next client render lazily started a fresh daemon (new pid) and returned
the rendered line with rc 0.

Default is 10 minutes; `0` disables auto-stop; any request (health included)
resets the clock, so busy periods keep the daemon alive without observers.

## Opt-in gating

The lazy start lives in the shared-mode client wrapper only — the file Claude
Code executes exclusively after `daemon install`. The one-shot render path
never spawns anything (covered by a test: one-shot render leaves the runtime
dir empty). `CCSTATUSLINE_NO_AUTOSTART=1` restores the fail-fast behavior.
Loading
Loading