fix(claude): stop claude_run_with_cost leaking errexit into its caller (igor#291) #296
No reviewers
Labels
No labels
Agent
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No assignees
2 participants
Notifications
Total time spent: 2 minutes 3 seconds
Due date
igor
2 minutes 3 seconds
No due date set.
Dependencies
No dependencies set
Reference
joshtronic/igor!296
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/claude-errexit-leak"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Root cause (reproduced, not guessed)
The tick died
status=1, noclaude exited Nline — the #279/#291 signature — wheneverclaudeexited nonzero mid-stream. It is an errexit leak, not OOM:claude_run_with_costruns its stream pipeline underset +e, then restored errexit with an unconditionalset -ebefore its bookkeeping. Every caller (PR-review, issue-work, maintenance, site-work) wraps the call inset +especifically to capture and handle a nonzero exit — but the function'sset -eclobbers that guard, so errexit is ON when the function returns. The nonzeroreturn $rcthen trips errexit in the caller and kills the tick before it can read$?. Theset -eadded to protect the bookkeeping is what defeated the caller's guard.I reproduced it in isolation (stub
claudethat emits a stream thenexit 1→ caller aborted before the line after the call) and in a 6-line minimal case.Fix
Capture the caller's errexit on entry, restore that (not an unconditional
set -e). The bookkeeping is already|| true-guarded, so it's safe with errexit off.bin/test-claude-errexit.shlocks it in — it fails against the old code (caller aborted) and passes against the fix (nonzero surfaces as a return value); errexit state preserved in both directions.Notes
make testgreen (check-sync + all 8 unit tests, incl. the new one). shellcheck clean on the changed lines.Closes #291
🤖 Review —
APPROVE(automated)CI for
9e2ff1d0: successSurgical fix to an errexit leak in
claude_run_with_cost: replaces an unconditionalset -erestore with one that restores the caller's captured errexit state. Correct, in scope, well-tested, CI green.Why the fix is right
lib/claude.sh:169captures_caller_errexitviacase $- in *e*)before the internal toggling, so it reflects the caller's state on entry (functions don't subshell, so$-is accurate here).$-correctly still showseeven when the call sits left of||(errexit is suppressed but the flag remains set), so the errexit-ON-with-|| truecaller path is handled.if [ "$_caller_errexit" -eq 1 ]; then set -e; else set +e; fi) is behaviorally identical to the oldset -ewhenever the caller had errexit on, and strictly safer (errexit off) when the caller had it off. So the only behavioral change is exactly the bug fix — the bookkeeping that previously ran under errexit-ON now runs under errexit-OFF forset +ecallers, which can only reduce the chance of an abort, never increase it. This means I don't have to fully see the (unshown) bookkeeping guards to be confident there's no regression.Test is genuinely discriminating
bin/test-claude-errexit.shreproduces the caller pattern in a standalone subshell captured via the parent'sset +e(not|| true), correctly avoiding the trap where bash disables errexit inside&&/||/ifoperands — which would have masked the very leak under test. I traced all three checks against both the buggy and fixed code: each fails on the old code (subshell aborts before writing the marker, or wrong errexit state) and passes on the fix. The ON-preserved check also implicitly guards against the capture being misplaced after the internalset +e.Non-blocking notes (no action required)
jq/timeout/tee/mktempis absent). If a CI runner lacked one of these, "make test green" could be a silent skip rather than a real pass — but the fix is sound on its own reasoning regardless.set +o pipefailis still restored unconditionally rather than to the caller's pipefail state. That's pre-existing, out of scope, and harmless to the crash (pipefail doesn't trigger an abort), but worth a future tidy if you ever want full state symmetry.Scope is ~133 lines, focused on the one issue; no CI-config or unrelated changes; no security surface; description checklist matches the diff. CI is
success.Independent review by the harness on
claude-opus-4-8. The human reviewer is requested once Igor has reviewed; a human still merges.