Skip to content

feat(daemon): on-demand lifecycle — lazy start, idle auto-stop, 20–30 session bench - #54

Merged
axisrow merged 1 commit into
mainfrom
ao/ccstatusline-28/daemon-53-ondemand
Oct 1, 2026
Merged

axisrow merged 1 commit into
mainfrom
ao/ccstatusline-28/daemon-53-ondemand

Conversation

@axisrow

@axisrow axisrow commented Oct 1, 2026

Copy link
Copy Markdown
Owner

feat(daemon): on-demand lifecycle — lazy start, idle auto-stop, 20–30 session bench

Closes #53. Builds on #44 (transport), #47 (cold-start lock / recovery), #49 (providers), #50 (install/gates).

What changes

Lazy start (client-side). The shared-mode wrapper client/ccstatusline-ipc, on finding no live daemon, runs ccstatusline daemon start (serialized by the #47 cold-start lock, so racing clients converge on one server) and retries the render into the fresh daemon. A connect failure against a daemon that died mid-request takes the same path. Bounded to one start attempt per invocation; CCSTATUSLINE_NO_AUTOSTART=1 restores fail-fast, CCSTATUSLINE_DAEMON_START overrides the start command (tests use it as a seam). The default start command resolves the entry from the wrapper's own install root (dist/ccstatusline.js via node, src/ccstatusline.ts via bun).

Idle auto-stop (daemon-side). daemonIdleStopMinutes in settings.json (default 10, 0 disables): after that window with zero requests the daemon releases its endpoints and exits. Any request — health included — resets the clock, so busy periods keep it alive with no observers and no session counting. The existing #47 recovery paths (stale socket, upgrade restart, bounded waits) apply to lazy starts unchanged.

Opt-in unchanged. The lazy start lives only in the wrapper Claude Code executes after daemon install; one-shot users never spawn anything (tested end-to-end: a one-shot render leaves the runtime dir empty).

Bench (docs/daemon-53-bench.md, raw: docs/daemon-53-results.json, harness: scripts/benchmark-lifecycle-53.py)

20 and 30 concurrent clients, mixed cold/warm caches (sessions alternate 500-row / 10 000-row transcripts and dirty / clean repos), no daemon pre-started:

sessions phase CPU/render one-shot shared (lazy) reduction p95 one-shot → shared
20 cold 188.0 ms 237.0 ms −26.0% 481 → 902 ms
20 warm 182.1 ms 42.4 ms 76.7% 451 → 271 ms
30 cold 186.4 ms 229.7 ms −23.2% 1035 → 1149 ms
30 warm 194.1 ms 49.4 ms 74.5% 934 → 481 ms

The cold wave pays the on-demand start once per idle period (every simultaneous first client spawns a starter; one wins the lock). Steady state keeps the #19/#50 win and halves p95.

Burst acceptance: one daemon (stable pid across waves) served each whole scenario; daemon counters ok=91, busy=85 with zero client failures — the bounded queue's 503s were fully absorbed by the client retry ladder. Idle acceptance at daemonIdleStopMinutes: 1: exited by itself after ~72 s (1 min + one 12 s check interval), discovery cleaned up, next render lazily restarted a fresh daemon (rc 0, new pid).

Tests

  • lazy start retries the render into the started daemon; 5 concurrent cold clients all render through one daemon (race)
  • connect-failure restart into a fresh daemon (discovery of a dead daemon mid-flight)
  • 503-during-burst through the retry ladder (queue depth 1, slow renders, both clients succeed)
  • idle auto-stop: stops after the window, a request keeps it alive past the deadline, 0 disables, onIdleStop fires
  • opt-in gating: one-shot render path spawns nothing (empty runtime dir)
  • bun test (2742 pass) and bun run lint fully green

Risks / notes

  • Cold-burst CPU regression (−23…−26%) is the once-per-idle-period cost of N racing starters; an install-time pre-start or a lighter starter could shave it if it ever matters.
  • One unrecorded bench run showed the dirty-repo line collapsing to the clean line under the cold 30-burst (git reads under load) — noted in the bench doc as a watch-item when re-running; the recorded run renders the expected two distinct lines with one-shot/shared outputs identical.

🤖 Generated with Claude Code

… session bench (#53)

Lazy start (client-side): the shared-mode IPC wrapper, on finding no live
daemon (or a connect failure against a dead one), runs `ccstatusline daemon
start` — serialized by the #47 cold-start lock — and transparently retries
the render into the fresh daemon. Bounded to one start per invocation;
CCSTATUSLINE_NO_AUTOSTART restores fail-fast, CCSTATUSLINE_DAEMON_START
overrides the command.

Idle auto-stop (daemon-side): after daemonIdleStopMinutes (settings.json,
default 10, 0 disables) with zero requests the daemon releases its
endpoints and exits; any request resets the clock, so busy periods keep it
alive with no observers or session counting.

Opt-in unchanged: the lazy start lives only in the wrapper Claude Code runs
after `daemon install`; the one-shot render path never spawns anything
(tested).

Bench (docs/daemon-53-bench.md, scripts/benchmark-lifecycle-53.py): 20 and
30 concurrent clients over mixed cold/warm caches vs one-shot. Warm bursts:
74.5–76.7% aggregate-CPU reduction and p95 cut roughly in half; the cold
wave pays the on-demand start once per idle period (−23…−26% vs one-shot).
One daemon served each whole burst (stable pid, 85 bounded-503 retries
absorbed by the ladder, zero failures); idle stop verified at 1 minute with
a transparent lazy restart.

Closes #53

Co-Authored-By: Claude Code <noreply@anthropic.com>
@axisrow
axisrow merged commit d87ffd7 into main Oct 1, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(daemon): on-demand lifecycle — lazy start + idle auto-stop — and a 20-30 session benchmark

1 participant