# fix(cli): make Ctrl-C escape shell loops and abort cleanly

`3fa7385`→[main](/content/gh/entireio/cli/commits/main/index.html)·

toothbrush·2w ago·1 file·+40 added/-0 removed

An interactive shell only aborts a `while true; do entire ...; done`
loop when the child is killed *by* SIGINT (WIFSIGNALED). entire trapped
SIGINT and exited normally, so every Ctrl-C just ended one iteration and
the loop instantly respawned entire — the user could never break out.

- On abort, re-raise SIGINT to self (dieFromInterrupt) so the process
dies by the signal and enclosing loops break on a single Ctrl-C.
- Second Ctrl-C force-quits, so a genuinely stuck shutdown still has an
escape hatch (the first signal now only cancels + warns).
- Treat context.Canceled as a silent 130 exit instead of dumping the raw
transport/keyring cancellation string as if it were a failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

## Sessions

be700a8a175cView transcript

## Changes

1. cmd/entire

- Mmain.go+40

```go
8 unmodified lines

9
10
11
12
13
14
15
16 unmodified lines

32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
29 unmodified lines

80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
19 unmodified lines

114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138

8 unmodified lines

"runtime"
	"strings"
	"syscall"
	"time"

"github.com/entireio/cli/cmd/entire/cli"
	"github.com/entireio/cli/cmd/entire/cli/api"
16 unmodified lines

}
	signal.Notify(sigChan, signals...)
	go func() {
		// First signal: cancel the context so in-flight work unwinds
		// cleanly. signal.Notify has disabled Go's default "SIGINT
		// terminates" behavior, so without the second read below a user
		// who Ctrl-C's again during a slow/stuck shutdown (e.g. a keyring
		// read blocked in a subprocess we can't cancel) would find every
		// further Ctrl-C swallowed. The second read restores an escape
		// hatch: press Ctrl-C again to force-exit with the conventional
		// 130 (128 + SIGINT).
		<-sigChan
		fmt.Fprintln(os.Stderr, "\nInterrupting… press Ctrl-C again to force quit.")
		cancel()
		<-sigChan
		dieFromInterrupt()
	}()

// Create and execute root command
29 unmodified lines

var silent *cli.SilentError

switch {
	case errors.Is(err, context.Canceled):
		// User aborted (Ctrl-C cancelled the root context). Don't dump
		// the raw transport/keyring cancellation string ("...: context
		// canceled", "read access token: signal: interrupt") as if it
		// were a failure — die quietly by re-raising SIGINT (see
		// dieFromInterrupt) so an enclosing `while ...; do entire; done`
		// loop actually breaks on a single Ctrl-C.
		cancel()
		dieFromInterrupt()
	case errors.As(err, &silent):
		// Command already printed the error
	case strings.Contains(err.Error(), "unknown command") || strings.Contains(err.Error(), "unknown flag"):
19 unmodified lines

cancel() // Cleanup on successful exit
}

// dieFromInterrupt terminates the process as if it had been killed by SIGINT,
// rather than exiting normally with code 130. The distinction matters to an
// interactive shell: it only aborts a `while true; do entire ...; done` loop
// when the child is *killed by* SIGINT (WIFSIGNALED). A plain os.Exit(130) is
// an ordinary exit, so the loop keeps respawning entire and Ctrl-C never
// escapes it. We reset SIGINT to its default disposition, re-raise it to
// ourselves, and briefly wait for delivery; if the re-raise can't be delivered
// (e.g. Windows, where os.Interrupt-to-self is unsupported) we fall back to a
// conventional exit so we never hang.
func dieFromInterrupt() {
	signal.Reset(os.Interrupt)
	if p, err := os.FindProcess(os.Getpid()); err == nil {
		if err := p.Signal(os.Interrupt); err == nil {
			time.Sleep(500 * time.Millisecond) // signal delivery ends the process well before this elapses
		}
	}
	os.Exit(130)
}

// isPositionalArgError reports whether err looks like a cobra positional-
// arg validator failure. cobra's stock validators (ExactArgs, NoArgs,
// MinimumNArgs, MaximumNArgs, RangeArgs) all surface error strings
