Skip to content

feat(custom-command): cache output behind an opt-in TTL and honor the timeout - #539

Open
zachthedev wants to merge 2 commits into
sirmalloc:mainfrom
zachthedev:feat-custom-command-cache
Open

feat(custom-command): cache output behind an opt-in TTL and honor the timeout#539
zachthedev wants to merge 2 commits into
sirmalloc:mainfrom
zachthedev:feat-custom-command-cache

Conversation

@zachthedev

Copy link
Copy Markdown
Contributor

Custom commands re-run on every status line repaint, and the timeout does not
actually bound how long a repaint takes. Two fixes, one commit each.

Caching (opt-in)

CustomCommand.render() calls out to the shell on every repaint, with no cache
and no in-flight guard. Git subprocess output already got a TTL cache in 6a581e6;
custom commands never did. This adds one, mirroring that pattern: entries are
keyed on the resolved command, cwd, session, width and timeout, stored under the
user cache dir with an atomic temp-plus-rename write.

The TTL defaults to 0, so nothing changes until you opt in. An existing config
spawns exactly as often as it does today. That is deliberate rather than matching
gitCacheTtlSeconds: 5: the git cache re-validates entries against .git/HEAD
and .git/index mtimes, so a stale entry self-corrects, while a custom command
has no invalidation signal at all. A non-zero default would silently freeze a
clock or ticker widget for up to 5 seconds after upgrade. Happy to switch it to 5
if you would rather have caching on by default.

Verified end-to-end against the built bundle: 4 renders produce 4 spawns at the
default, and 1 spawn at customCommandCacheTtlSeconds: 5.

Timeout and process tree

execSync with a shell enforced its timeout by terminating the shell alone, so a
pipeline's grandchildren survived as running orphans. Worse, the render blocked
for as long as any descendant held an inherited stdio pipe: a command that
backgrounds a job, or invokes a tool leaving a helper behind, stalled the repaint
well past its budget and then reported [Timeout] for a command that had exited
successfully.

Both stdio streams now go to files in a per-run mkdtemp directory, so no
descendant inherits a handle the parent must wait on. Measured end-to-end through
the built bundle under Node, a command backgrounding a 3s job went from 3672ms
rendering EARLY LATE
to 542-702ms rendering EARLY. On timeout the
process group is signalled with detached plus kill(-pid) on POSIX, which was
verified to reach pipeline members. The mkdtemp directory is owner-only and
unguessable, which also closes a predictable temp path the payload previously
used.

Scope, honestly

This does not fix a ccstatusline crash or leak; it reduces how much work a repaint
does. On my machine I found ~144 stranded processes holding ~580MB, but the root
cause of those is harness-level: Claude Code kills the status line renderer
between process creation and resume, leaving children that never ran. ccstatusline
is the amplifier, because it spawns unboundedly per repaint, and cutting spawn
volume is the lever available from this side. I did not want to overclaim that.

Known and not addressed

  • Two status line processes writing the cache concurrently can lose an entry to a
    read-modify-write race. It costs an extra command run, never corrupts the file
    (the temp-plus-rename is atomic), and locking seemed disproportionate.
  • The on-disk cache is unauthenticated, so anything that can already write to your
    user cache dir can make a widget print chosen bytes. That is defence-in-depth
    only, since same-uid write access implies prior compromise.

Tests

bun run lint clean. bun test 1912 pass, 2 skip, 1 fail; the failure is
global command resolution > silences child stderr on best-effort probes, which
is pre-existing on main on this host (it passes in isolation and fails in a full
run, shared-spy pollution across files) and unrelated to this change. New coverage
for cache hit/miss/TTL expiry/key separation and for the timeout path.

The custom command widget called execSync on every status line repaint,
so one configured command spawned a shell, and that shell's whole
pipeline, as often as Claude Code repainted. Nothing bounded the rate.

Cache the result the way git.ts already caches git subprocess output:
an in-process map backed by a JSON file under ~/.cache/ccstatusline.
The persistent half is the half that matters. Claude Code runs the
status line as a fresh process per repaint, so an in-process map on its
own would never hit.

Entries key on the command, the session id and the terminal width. The
rest of the piped payload is deliberately excluded, because it carries
token counts that change on nearly every repaint and would turn every
lookup into a miss.

customCommandCacheTtlSeconds defaults to 5s, matching the existing
gitCacheTtlSeconds, and is editable from the same Configure Status Line
screen. Setting it to 0 turns caching off and runs the command on every
repaint.

The cache file is written owner-only. Git metadata is predictable,
whereas a custom command prints whatever its author chose to print.
execSync with a shell enforced its timeout by terminating the shell alone, so a
pipeline's grandchildren survived as running orphans. Worse, the render blocked
for as long as any descendant held an inherited stdio pipe, so a command that
backgrounded a job stalled a repaint well past its budget and then reported
[Timeout] for a command that had exited successfully.

Both stdio streams now go to files in a per-run mkdtemp directory, so no
descendant inherits a handle the parent must wait on and the timeout is exact.
Measured end-to-end through the built bundle under Node, a command backgrounding
a 3s job went from 3672ms rendering "EARLY LATE" to 542-702ms rendering "EARLY".
On timeout the process group is signalled with detached plus kill(-pid) on POSIX,
which reaches pipeline members. The mkdtemp directory is owner-only and
unguessable, so a pre-existing symlink cannot redirect the payload write and the
file cannot be swapped between write and open.

Corrections to the cache in the same change, since they share the module: the
entry timestamp is taken after the command returns, so a command slower than the
TTL is still cached; captured output is capped and maxBuffer pinned rather than
inherited; the entry key includes the timeout, so two widgets sharing a command
string no longer inherit each other's failures; a request with no session id
stays out of the shared file; and the TTL reaches the widget through
RenderContext, matching how the git cache TTL is plumbed.

The cache is opt-in: the TTL defaults to 0, so an existing config spawns exactly
as often as it does today until the user sets one.
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.

1 participant