88 "os/signal"
99 "runtime"
1010 "strings"
11+ "sync/atomic"
1112 "syscall"
13+ "time"
1214
1315 "github.com/entireio/cli/cmd/entire/cli"
1416 "github.com/entireio/cli/cmd/entire/cli/api"
@@ -31,8 +33,27 @@ func main() {
3133 }
3234 signal .Notify (sigChan , signals ... )
3335 go func () {
34- <- sigChan
36+ // First signal: cancel the context so in-flight work unwinds
37+ // cleanly. signal.Notify has disabled Go's default "signal
38+ // terminates" behavior, so without the second read below a user
39+ // who Ctrl-C's again during a slow/stuck shutdown (e.g. a keyring
40+ // read blocked in a subprocess we can't cancel) would find every
41+ // further Ctrl-C swallowed. The second read restores an escape
42+ // hatch: signal again to force-exit.
43+ //
44+ // We remember which signal fired so the eventual termination
45+ // re-raises that same signal — a SIGTERM (from a supervisor /
46+ // container stop) must exit 143, not masquerade as a SIGINT 130.
47+ sig := <- sigChan
48+ caughtSignal .Store (sig )
49+ if sig == os .Interrupt {
50+ fmt .Fprintln (os .Stderr , "\n Interrupting… press Ctrl-C again to force quit." )
51+ } else {
52+ fmt .Fprintln (os .Stderr , "\n Received termination signal, shutting down… signal again to force quit." )
53+ }
3554 cancel ()
55+ <- sigChan
56+ dieFromSignal (sig )
3657 }()
3758
3859 // Create and execute root command
@@ -68,6 +89,22 @@ func main() {
6889 var silent * cli.SilentError
6990
7091 switch {
92+ case errors .Is (err , context .Canceled ) && caughtSignal .Load () != nil :
93+ // A signal cancelled the root context (our handler fired). Don't
94+ // dump the raw transport/keyring cancellation string ("...:
95+ // context canceled", "read access token: signal: interrupt") as
96+ // if it were a failure — die quietly by re-raising the signal
97+ // that triggered it (see dieFromSignal) so an enclosing
98+ // `while ...; do entire; done` loop actually breaks on a single
99+ // Ctrl-C, and a SIGTERM shutdown still exits 143.
100+ //
101+ // We gate on the handler having fired rather than on the error
102+ // type alone: a context.Canceled that arose without a signal
103+ // (e.g. an internally-cancelled sub-context) is a genuine error
104+ // and must fall through to normal reporting, not masquerade as a
105+ // user abort (which would also wrongly break an enclosing loop).
106+ cancel ()
107+ dieFromSignal (terminatingSignal ())
71108 case errors .As (err , & silent ):
72109 // Command already printed the error
73110 case strings .Contains (err .Error (), "unknown command" ) || strings .Contains (err .Error (), "unknown flag" ):
@@ -93,6 +130,53 @@ func main() {
93130 cancel () // Cleanup on successful exit
94131}
95132
133+ // caughtSignal records the terminating signal (SIGINT or SIGTERM) the handler
134+ // observed, so a later cancellation-driven exit can re-raise the *same* signal
135+ // rather than always SIGINT. Read via terminatingSignal.
136+ var caughtSignal atomic.Value // stores os.Signal
137+
138+ // terminatingSignal returns the signal that cancelled the root context,
139+ // defaulting to SIGINT when the cancellation came from something other than
140+ // our signal handler (so a stray context.Canceled still exits 130).
141+ func terminatingSignal () os.Signal {
142+ if v := caughtSignal .Load (); v != nil {
143+ if s , ok := v .(os.Signal ); ok {
144+ return s
145+ }
146+ }
147+ return os .Interrupt
148+ }
149+
150+ // dieFromSignal terminates the process as if it had been killed by sig, rather
151+ // than exiting normally. The distinction matters to an interactive shell: it
152+ // only aborts a `while true; do entire ...; done` loop when the child is
153+ // *killed by* SIGINT (WIFSIGNALED). A plain os.Exit(130) is an ordinary exit,
154+ // so the loop keeps respawning entire and Ctrl-C never escapes it. Re-raising
155+ // the actual signal also keeps a SIGTERM shutdown reporting the conventional
156+ // 143 (not 130). We reset sig to its default disposition, re-raise it to
157+ // ourselves, and briefly wait for delivery; if the re-raise can't be delivered
158+ // (e.g. Windows, where signal-to-self is unsupported) we fall back to a
159+ // conventional 128+signal exit so we never hang.
160+ func dieFromSignal (sig os.Signal ) {
161+ signal .Reset (sig )
162+ if p , err := os .FindProcess (os .Getpid ()); err == nil {
163+ if err := p .Signal (sig ); err == nil {
164+ time .Sleep (500 * time .Millisecond ) // signal delivery ends the process well before this elapses
165+ }
166+ }
167+ os .Exit (exitCodeForSignal (sig ))
168+ }
169+
170+ // exitCodeForSignal maps a signal to the conventional 128+signum exit code
171+ // (130 for SIGINT, 143 for SIGTERM), falling back to 130 for a signal that
172+ // doesn't carry a numeric value on this platform.
173+ func exitCodeForSignal (sig os.Signal ) int {
174+ if s , ok := sig .(syscall.Signal ); ok {
175+ return 128 + int (s )
176+ }
177+ return 130
178+ }
179+
96180// isPositionalArgError reports whether err looks like a cobra positional-
97181// arg validator failure. cobra's stock validators (ExactArgs, NoArgs,
98182// MinimumNArgs, MaximumNArgs, RangeArgs) all surface error strings
0 commit comments