Fix Ctrl-C Signal Handling in CLI

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
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.
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 rawdo 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: interruptfrom the keyring isn'tcontext.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. MakingcallKeyringWithTimeoutcontext-aware (keyring_timeout.gousescontext.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.Exitplumbing inpackage 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?
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 1in the loop and Ctrl-C during the sleep —sleepdies by SIGINT, which the shell does honor.
The full change (cmd/entire/main.go, +40 lines, lint clean)
- Re-raise SIGINT on abort → shells break loops on one Ctrl-C.
- Double-Ctrl-C escalation → second Ctrl-C force-quits if something's genuinely stuck (e.g. the uncancellable Keychain subprocess read).
- Silent
context.Canceled→ no more rawdo request: Post ".../mirrors": … context canceled/signal: interruptspew 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.
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 lint0 issues.- New test
TestCallKeyringWithInterrupt_AbortsOnSignal— asserts prompt return (<2s vs 10s timeout) wrappingcontext.Canceled. tokenstore+authpackage 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?
Done. PR #1604: https://github.com/entireio/cli/pull/1604
- 2 commits pushed to
serialized-plotting-thompson,test:cigreen before push. ghneeded-R entireio/clisince origin is anentire://mirror URL, not a github.com remote.
look at the review comments: https://github.com/entireio/cli/pull/1604
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.
Both replies posted to the inline threads on #1604:
main.go:85(SIGTERM masquerading as SIGINT) → explained thecaughtSignalcapture +dieFromSignalre-raising the actual signal (130/143), with the stray-context.Canceleddefault 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.
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.