Fix Ctrl-C Signal Handling in CLI

Claude Code·Opus 4.8[1m]·toothbrush·2mo ago·20hr 9min·4 Checkpoints·6 file changes·+218/-38·171.8K tokens

wow that was a poor experience why can't i control-c

✓ Cloning entirehq/entire.io into eukanuba.partial.to Mirror already exists (01KWBK9B0JMY40DTPV1TPW693T) entire://eukanuba.partial.to/gh/entirehq/entire.io

Clone it: git clone entire://eukanuba.partial.to/gh/entirehq/entire.io do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled security "BearerAuth": security source "BearerAuth": refresh login token: load core token: read access token: signal: interrupt security "BearerAuth": security source "BearerAuth": refresh login token: load core token: read access token: signal: interrupt security "BearerAuth": security source "BearerAuth": refresh login token: load core token: read access token: signal: interrupt security "BearerAuth": security source "BearerAuth": refresh login token: load core token: read access token: signal: interrupt security "BearerAuth": security source "BearerAuth": refresh login token: load core token: read access token: signal: interrupt security "BearerAuth": security source "BearerAuth": refresh login token: load core token: read access token: signal: interrupt security "BearerAuth": security source "BearerAuth": refresh login token: load core token: read access token: signal: interrupt security "BearerAuth": security source "BearerAuth": refresh login token: load core token: read access token: signal: interrupt security "BearerAuth": security source "BearerAuth": refresh login token: load core token: read access token: signal: interrupt do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled do request: Post "https://eu.auth.partial.to/api/v1/mirrors": cross-juris transport: base round trip: context canceled

2mo ago·7m

Bottom line: you can't Ctrl-C because main.go only listens for one signal, then goes deaf — and the token read shells out to macOS Keychain, which isn't cancellable. So the first Ctrl-C is half-handled and every Ctrl-C after it is silently dropped. The error spew is the CLI printing raw transport/keyring cancellation strings instead of a clean "aborted."

Root causes

1. Repeated Ctrl-C is swallowed — the real "why can't I control-c" (cmd/entire/main.go:33-36):

signal.Notify disables Go's default "SIGINT terminates the process" for the whole run. After the first Ctrl-C cancels the context, nothing reads sigChan again, so further Ctrl-C hits the buffer and does nothing. There's no force-quit escalation. You were mashing Ctrl-C into a void.

2. Cancellation prints as errors, not a clean abort. On the one-shot mirror create path, CreateMirror returns the raw ogen error on cancel; renderCoreError passes it straight through; main.go prints it — hence do request: Post ".../mirrors": ... context canceled. The wizard path already handles this (if ctx.Err() != nil { return NewSilentError(...) } at repo_mirror_create_wizard.go:313), but the one-shot and the generic runCoreClient/main.go paths don't swallow context.Canceled.

3. signal: interrupt from the token read — tokenstore on macOS uses zalando/go-keyring, which shells out to /usr/bin/security. callKeyringWithTimeout runs on context.Background() (keyring_timeout.go:47), so your Ctrl-C can't cancel it — it only kills the security child, which surfaces as read access token: signal: interrupt. The repeated lines are in-flight goroutines + your repeated Ctrl-C each reporting their own cancellation.

The fix

Small, standard, high-value: first Ctrl-C cancels + warns, second Ctrl-C force-exits 130, and treat context.Canceled as a silent abort instead of an error dump.

Plus: in main.go's error switch, exit 130 silently when errors.Is(err, context.Canceled), and have the one-shot mirror path wrap cancel as NewSilentError like the wizard already does.

Want me to implement it? I'd do the main.go double-signal escalation + silent context.Canceled handling (fixes every command, not just mirror), and optionally the one-shot mirror cancel-swallowing. The keyring cancellability is a bigger change I'd leave for a follow-up.

yep do it

2mo ago·2m

Done. Both fixes are in cmd/entire/main.go, builds clean, lint passes, only that file changed.

What changed

1. Double-Ctrl-C escalation (the "why can't I control-c" fix):

  • First Ctrl-C prints Interrupting… press Ctrl-C again to force quit. and cancels the context.
  • Second Ctrl-C os.Exit(130) — hard quit, no matter what's stuck (including the uncancellable Keychain subprocess read). Previously the handler read one signal and went deaf.

2. Silent abort on cancellation:

  • errors.Is(err, context.Canceled) now exits 130 quietly instead of dumping raw do request: Post ".../mirrors": ... context canceled. This is global — covers the one-shot mirror path and every other command, not just mirror.

Notes / follow-ups I did not do

  • signal: interrupt from the keyring isn't context.Canceled, so a single Ctrl-C mid-Keychain-read can still print that one line — but the second Ctrl-C now force-quits before it can spew repeatedly. Making callKeyringWithTimeout context-aware (keyring_timeout.go uses context.Background()) is the deeper fix; it's a bigger change, left for a follow-up as discussed.
  • No unit test added — the change is signal/os.Exit plumbing in package main, which has no existing test harness and isn't reachable by other packages.

I didn't run the full test:ci suite since the diff is isolated to main.go with no shared logic; say the word if you want me to run it before you push.

ah now i see it. i ran it like this:

is there anything i can do to be able to C-c out of that?

2mo ago·9m

Clean. Here's the answer.

Yes — and the fix is now in place

Why Ctrl-C couldn't break your loop: entire traps SIGINT and exits normally. A shell only aborts a while loop when the foreground child was killed by SIGINT (WIFSIGNALED) — not when it exits cleanly, even with code 130. So every Ctrl-C just ended one iteration; the loop instantly respawned entire, and you were racing an endless stream of fresh processes. My first patch (os.Exit(130)) was still a normal exit, so it wouldn't have helped your loop either.

The fix: on interrupt, entire now re-raises SIGINT to itself (dieFromInterrupt) so it dies by the signal. I proved it end-to-end with a PTY test driving your shell (zsh): with the fix a single Ctrl-C breaks while true; do work; done (0 iterations after); with the old behavior the loop ran 3 more times.

What you can do right now (before rebuilding/reinstalling)

Any of these break the loop with the current binary:

  • while entire mirror create …; do :; done — loops only while it exits 0; a single Ctrl-C makes it exit non-zero (with my patch, 130) and the loop stops.
  • while true; do entire mirror create … || break; done — same idea, explicit.
  • Put a sleep 1 in the loop and Ctrl-C during the sleep — sleep dies by SIGINT, which the shell does honor.

The full change (cmd/entire/main.go, +40 lines, lint clean)

  1. Re-raise SIGINT on abort → shells break loops on one Ctrl-C.
  2. Double-Ctrl-C escalation → second Ctrl-C force-quits if something's genuinely stuck (e.g. the uncancellable Keychain subprocess read).
  3. Silent context.Canceled → no more raw do request: Post ".../mirrors": … context canceled / signal: interrupt spew on abort.

Not committed/pushed. Want me to run the full test:ci before you push? (The diff is isolated to package main, which has no test harness, so I skipped it.)

One known gap left for a follow-up: the Keychain read (callKeyringWithTimeout) uses context.Background(), so it isn't cancellable mid-read — the second-Ctrl-C force-quit is the escape hatch there.

callKeyringWithTimeout) uses context.Background

let's fix that while we're here.

2mo ago·4m

Done. The keyring read is now interrupt-cancellable.

What changed (internal/entireclient/tokenstore/keyring_timeout.go)

callKeyringWithTimeout now listens for SIGINT for the duration of the call:

  • Ctrl-C unblocks a stuck keyring read immediately — no more waiting out the full 5s timeout (or hanging forever on a headless box with no keyring daemon).
  • The abort returns an error wrapping context.Canceled, so it flows into the CLI's silent "user aborted" exit path (main.go) instead of printing as a keyring failure.
  • Extracted a testable core (callKeyringWithInterrupt) with an injectable interrupt channel, so the abort path is unit-tested without firing real signals at the test process.

Why the signal approach, not context threading

The credential store is reached through auth-go's Store interface (LoadTokens(issuer string) / SaveTokens), which carries no context.Context. So a per-request ctx literally cannot be threaded down to this point on the hot path — the exact path that produced your read access token: signal: interrupt. signal.Notify fans out to every registered channel, so main.go's handler still cancels the root context; this is just an additional listener. It fixes cancellability for all callers (login, logout, and the auth-go-mediated refresh) with no signature churn across the 12 call sites.

Verified

  • go build ./... clean, mise run lint 0 issues.
  • New test TestCallKeyringWithInterrupt_AbortsOnSignal — asserts prompt return (<2s vs 10s timeout) wrapping context.Canceled.
  • tokenstore + auth package tests pass.

Total across both turns: main.go (signal escalation + re-raise + silent cancel) and keyring_timeout.go (interrupt-cancellable keyring). Nothing committed — yours to review and push. Want the full test:ci run before you push?

great. commit/push/PR.

2mo ago·5m

Done. PR #1604: https://github.com/entireio/cli/pull/1604

  • 2 commits pushed to serialized-plotting-thompson, test:ci green before push.
  • gh needed -R entireio/cli since origin is an entire:// mirror URL, not a github.com remote.

look at the review comments: https://github.com/entireio/cli/pull/1604

2mo ago·3m

Both Copilot comments were valid and are now fixed in commit a81f2bfb (pushed to #1604).

The issue: the handler catches SIGTERM too (non-Windows), but I unconditionally re-raised SIGINT + printed "press Ctrl-C again" — so a supervisor/container SIGTERM would exit 130 instead of 143 with a misleading message.

Fix:

  • Capture which signal fired (caughtSignal), re-raise that signal → SIGINT→130, SIGTERM→143 (128+signum).
  • First-signal message is now signal-specific ("Interrupting… press Ctrl-C again" vs. "Received termination signal, shutting down…").
  • Verified the exit codes with a standalone check: SIGINT→130, SIGTERM→143.

Build + lint clean. The two other findings were the overview summary (not actionable). CI will re-run on the new push.

respond to the findings.

2mo ago·35.7s

Both replies posted to the inline threads on #1604:

  • main.go:85 (SIGTERM masquerading as SIGINT) → explained the caughtSignal capture + dieFromSignal re-raising the actual signal (130/143), with the stray-context.Canceled default noted.
  • main.go:45 (misleading hint) → explained the now signal-specific first-signal message.

Both reference the fix commit a81f2bfb.

there are some findings, posted to https://entire.io/gh/entireio/cli/trails/730 - fish them out with entire trail ... and fix them.

2mo ago·4m

Both findings fished out, fixed, and resolved on trail 730.

Finding 1 (broad context.Canceled match) — gated the abort path on caughtSignal.Load() != nil, so only a real signal-driven cancellation dies via dieFromSignal. A signal-less context.Canceled (internal sub-context) now falls through to normal error reporting — no fake user-abort, no wrongful loop-break.

Finding 2 (exitCodeForSignal untested) — added cmd/entire/main_test.go with a table test locking SIGINT→130, SIGTERM→143, plus the non-numeric fallback.

Committed in 54a30ecafe, pushed, build + lint + tests green, both findings marked resolved with reasons pointing at the commit.

Note: the push printed warning: helper reported unexpected status of refs/heads/remove-checkpoint-version-from-metadata — that's an unrelated ref from another branch/worktree in the shared checkpoint push, not your branch (which updated fine). Flagging in case it recurs.