feat(custom-command): cache output behind an opt-in TTL and honor the timeout - #539
Open
zachthedev wants to merge 2 commits into
Open
feat(custom-command): cache output behind an opt-in TTL and honor the timeout#539zachthedev wants to merge 2 commits into
zachthedev wants to merge 2 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 cacheand 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/HEADand
.git/indexmtimes, so a stale entry self-corrects, while a custom commandhas 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
execSyncwith a shell enforced its timeout by terminating the shell alone, so apipeline'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 exitedsuccessfully.
Both stdio streams now go to files in a per-run
mkdtempdirectory, so nodescendant 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 LATEto 542-702ms renderingEARLY. On timeout theprocess group is signalled with
detachedpluskill(-pid)on POSIX, which wasverified to reach pipeline members. The
mkdtempdirectory is owner-only andunguessable, 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
read-modify-write race. It costs an extra command run, never corrupts the file
(the temp-plus-rename is atomic), and locking seemed disproportionate.
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 lintclean.bun test1912 pass, 2 skip, 1 fail; the failure isglobal command resolution > silences child stderr on best-effort probes, whichis pre-existing on
mainon this host (it passes in isolation and fails in a fullrun, 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.