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
2 changes: 1 addition & 1 deletion plugins/rate-limit-guard/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "rate-limit-guard",
"version": "0.3.8",
"version": "0.4.0",
"description": "Shared rate-limit guard for loop lanes: a statusline wrapper tees the subscription rate-limit windows to a fixed machine-scope file, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume.",
"author": {
"name": "Melodic Software",
Expand Down
58 changes: 58 additions & 0 deletions plugins/rate-limit-guard/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,64 @@
All notable changes to the `rate-limit-guard` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.4.0]

### Fixed

- **The statusline tee no longer leaks its atomic-write temp file when the harness cancels it
(#1807).** Claude Code
[cancels an in-flight statusline script](https://code.claude.com/docs/en/statusline) when a new
update arrives while the previous one is still running, and a cancellation between the write and
the rename left the temp file behind permanently — no failed `rm` was needed to explain it, the
process simply never reached the reclaim line. The only reclaim paths were write-failure and
retry-exhaustion. 61 orphans were found clustered in one busy 27-hour window, which is the shape
the correlation predicts: the rename retry loop holds the file open longest exactly when the
target is contended, which is also when the session is busy enough to trigger a cancelling update.

Two mechanisms ship, because neither is sufficient alone. A trap reclaims on exit and on a
catch-able signal; an age-filtered sweep of leftover siblings on the next refresh recovers what a
SIGKILL, a crash, or power loss leaves, which no trap can. Reproduced with an `mv` shim that parks
so the kill lands inside the window: **before**, SIGTERM and SIGKILL each leak one file;
**after**, SIGTERM leaks none and a SIGKILL orphan is reclaimed by the next refresh.

The sweep costs nothing on a clean directory — a shell glob decides whether to spawn anything at
all, so a normal refresh runs no extra process on a path that already sits at two to four times
the 300 ms debounce interval. Its one-minute age floor cannot race a concurrent session's live
temp, whose write-to-rename window is sub-second and bounded by the 300 ms retry loop.

- **A session with no rate-limit windows no longer overwrites a snapshot that has them (#1807).**
On a mixed-auth machine an API-key or enterprise session would land a snapshot with `rate_limits`
absent and a **fresh** `captured_at`, so consumers never saw "stale" — they saw a current snapshot
with no data and dropped to whole-guard reactive-only, on a machine where a window-bearing session
had good data available. Each such landing could destroy up to the reader contract's full
ten-minute staleness budget of usable proactive data.

The tee now skips the write when this session has no `rate_limits` and the target already has
them. Window-bearing is decided structurally (jq `has("rate_limits")`, on the payload and on the
target) — never by substring, which a forwarded value merely containing the string
`"rate_limits"` (e.g. a session name) would defeat and clobber real windows. The preservation
decision is serialized with the rename through a `mkdir`-based writer lock (atomic everywhere
this runs, including Git Bash where `flock` is unavailable; a lock left by a killed writer is
stolen past the same one-minute age floor the temp sweep uses), because an unserialized
check-then-write let a windowless writer pass its check, lose the CPU to a window-bearing
writer's rename, and clobber the fresh windows anyway. On lock-acquisition failure the
windowless writer skips its write and the window-bearing writer proceeds unlocked —
last-writer-wins between window-bearing snapshots is the pre-existing contract. The orphan sweep
runs before the preservation early-return, so a machine where only windowless sessions remain
active still reclaims a killed session's temp file. A windowless session still writes when the
target has no windows either, so a machine with no window-bearing session keeps an honest
staleness signal rather than an empty directory.

### Changed

- **`reference/reader-contract.md` inventories the temp-file shape.** The directory listing named
`stop-events.jsonl.lock` and explicitly told tooling sweeping the directory to expect it, while
omitting the only litter actually found there. It now documents
`.rate-limits.json.tmp.<pid>.<random>`, why it can outlive its writer, and that a cleanup tool
should leave it alone — one may belong to a live concurrent session, and the tee reclaims them
itself. The script header's atomicity comment says the same, rather than implying the rename is
the only outcome.

## [0.3.8]

### Fixed
Expand Down
15 changes: 12 additions & 3 deletions plugins/rate-limit-guard/reference/reader-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,18 @@ The hook is side-effect-only (the harness ignores StopFailure output and exit co
payload carries no reset or quota data — a record means "a rate limit stopped a turn at this time",
nothing more. The file is bounded (rotated to the newest 100 records past 200).

The contract directory holds one more file: `stop-events.jsonl.lock`, the advisory-lock sibling the
hook's serialized append and rotation use (present wherever `flock` exists). Readers ignore it; it
is part of the seam only in the sense that tooling sweeping the directory should expect it.
The contract directory holds two more shapes, neither of which readers consume, listed so tooling
sweeping the directory expects them:

- `stop-events.jsonl.lock` — the advisory-lock sibling the hook's serialized append and rotation use
(present wherever `flock` exists).
- `.rate-limits.json.tmp.<pid>.<random>` — the tee's atomic-write staging file. Normally it exists
for well under a second between write and rename. It can outlive its writer: Claude Code
[cancels an in-flight statusline script](https://code.claude.com/docs/en/statusline) when a new
update arrives, and a cancellation inside that window leaves the file behind. The tee reclaims its
own on exit and on a catch-able signal, and sweeps siblings older than a minute on the next
refresh, which is what recovers from a SIGKILL, a crash, or power loss. A cleanup tool should
leave these alone: one may belong to a live concurrent session, and the tee reclaims them itself.

## Invariants and boundaries

Expand Down
136 changes: 134 additions & 2 deletions plugins/rate-limit-guard/scripts/statusline-tee.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@
# jq, unwritable path, failed rename — ever alters the wrapped statusline's
# output or exit code.
#
# TEMP-FILE RECLAIM: a temp file can outlive this process. Claude Code
# "cancels the in-flight script" when a new update arrives while this one is
# still running (https://code.claude.com/docs/en/statusline), and a
# cancellation between the write and the rename leaves the temp behind — no
# failed rm is needed to explain it, the process simply never reaches the
# reclaim line. Two mechanisms, because neither is sufficient alone: a trap
# reclaims on exit and on a catch-able signal, and an age-filtered sweep of
# leftover siblings recovers what a SIGKILL, a crash, or power loss leaves,
# which no trap can. The sweep costs nothing on a clean directory — a glob
# decides whether to spawn anything at all — and it cannot race a live
# sibling, since the normal write-to-rename window is sub-second while the
# age floor is a minute.
#
# jq is required for the tee and for the standalone line; when absent the
# wrapper stays transparent and appends a visible one-line notice instead of
# silently dropping the feature.
Expand All @@ -62,6 +75,81 @@ else
IFS= read -r -d '' -t 5 INPUT || true
fi

# Path of the temp file currently in flight, for the reclaim traps below. A
# global rather than the function's local: the trap body is evaluated when the
# trap fires, by which time the function's locals are gone.
TEE_TMP=""

reclaim_tee_tmp() {
[[ -n "$TEE_TMP" ]] && rm -f "$TEE_TMP" 2>/dev/null
TEE_TMP=""
return 0
}

# Lock directory currently held (see acquire_tee_lock below); a global for
# the same trap-lifetime reason as TEE_TMP, and defined before the trap is
# installed so an early exit never references an undefined function.
TEE_LOCK=""

release_tee_lock() {
[[ -n "$TEE_LOCK" ]] && rmdir "$TEE_LOCK" 2>/dev/null
TEE_LOCK=""
return 0
}

# The signal traps exit rather than reclaiming directly, so the EXIT trap stays
# the single reclaim path. Exiting is also the right response to a cancelling
# signal: this refresh's snapshot is already superseded by the update that
# cancelled it.
trap 'reclaim_tee_tmp; release_tee_lock' EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
trap 'exit 129' HUP

# Reclaim temp siblings left by a process that never got to clean up. Only
# files older than the age floor are touched, so a concurrent session's live
# temp — sub-second between write and rename — is never a candidate.
#
# The glob runs first and decides whether to spawn at all: on a clean
# directory, which is every refresh in normal operation, this costs zero
# processes on a path that already runs at two to four times the statusline
# debounce interval.
sweep_stale_tee_temps() {
local dir="$1" candidate
for candidate in "$dir"/.rate-limits.json.tmp.*; do
[[ -e "$candidate" ]] || continue
find "$dir" -maxdepth 1 -type f -name '.rate-limits.json.tmp.*' \
-mmin +1 -exec rm -f {} + 2>/dev/null || true
return 0
done
return 0
}

# Serialize the preservation decision with the rename. Check-then-write
# without mutual exclusion lets a windowless writer pass its check, lose the
# CPU to a window-bearing writer's rename, and then clobber the fresh windows
# anyway — concurrent sessions are the normal operating model here. The lock
# is a directory (mkdir-as-lock is atomic on every platform this runs on,
# including Git Bash on Windows, where flock is unavailable). A holder killed
# between mkdir and rmdir would leave the lock forever, so a contender steals
# any lock older than the same one-minute age floor the temp sweep uses —
# far above the sub-second hold time of a live writer. The release side lives
# with the traps above.
acquire_tee_lock() {
local dir="$1" lock="$1/.rate-limits.json.lock" _try
# shellcheck disable=SC2034 # bounded-retry counter; the value itself is unused
for _try in 1 2 3; do
if mkdir "$lock" 2>/dev/null; then
TEE_LOCK="$lock"
return 0
fi
find "$dir" -maxdepth 1 -type d -name '.rate-limits.json.lock' \
-mmin +1 -exec rmdir {} + 2>/dev/null || true
sleep 0.1 2>/dev/null || true
done
return 1
}

# Write one contract snapshot. Every failure path returns 0: the tee must
# never propagate into the statusline pipeline.
tee_snapshot() {
Expand All @@ -86,25 +174,69 @@ tee_snapshot() {
' 2>/dev/null) || return 0
[[ -n "$payload" ]] || return 0

# Window-bearing is a structural property — jq's has(), never a substring
# test: a forwarded value that merely contains the string "rate_limits"
# (e.g. "session_name":"rate_limits") must not count as window-bearing and
# overwrite a snapshot holding real windows.
local has_windows=false
jq -e 'has("rate_limits")' >/dev/null 2>&1 <<<"$payload" && has_windows=true

# Sweep before any early return: a machine where only windowless sessions
# remain active would otherwise skip below on every refresh and never
# reclaim the orphan a killed window-bearing session left behind.
sweep_stale_tee_temps "$dir"

# All writers take the lock around [check+]rename — serialization needs
# both parties. On acquisition failure the windowless writer skips its
# write (nothing precious is lost; the reader treats absence reactively),
# while the window-bearing writer proceeds unlocked: its payload carries
# data, and last-writer-wins between two window-bearing snapshots is the
# pre-existing contract.
if ! acquire_tee_lock "$dir"; then
[[ "$has_windows" == true ]] || return 0
fi

# A session with no windows — API-key or enterprise auth — must not overwrite
# a snapshot that HAS them. The reader contract routes a snapshot missing
# rate_limits to whole-guard reactive-only, and this write would carry a FRESH
# captured_at, so consumers would never see "stale" and would instead see a
# current snapshot with no data: up to the contract's full 10-minute staleness
# budget of usable proactive data destroyed on a mixed-auth machine, silently.
# A target jq cannot parse counts as windowless — torn or corrupt content is
# exactly what an atomic overwrite should replace.
if [[ "$has_windows" != true && -f "$target" ]]; then
if jq -e 'has("rate_limits")' "$target" >/dev/null 2>&1; then
release_tee_lock
return 0
fi
fi

local tmp="$dir/.rate-limits.json.tmp.$$.$RANDOM"
TEE_TMP="$tmp"
# Subshell umask so the snapshot lands owner-only without altering the
# umask the wrapped statusline command inherits.
(
umask 077
printf '%s\n' "$payload" >"$tmp"
) 2>/dev/null || {
rm -f "$tmp" 2>/dev/null
reclaim_tee_tmp
release_tee_lock
return 0
}
local _try
# shellcheck disable=SC2034 # bounded-retry counter; the value itself is unused
for _try in 1 2 3; do
if mv -f "$tmp" "$target" 2>/dev/null; then
# The temp path is the target now; clear it so the EXIT trap cannot
# reclaim a name that no longer refers to this refresh's file.
TEE_TMP=""
release_tee_lock
return 0
fi
sleep 0.1 2>/dev/null || true
done
rm -f "$tmp" 2>/dev/null
reclaim_tee_tmp
release_tee_lock
return 0
}

Expand Down
Loading
Loading