Skip to content

Commit 6a41271

Browse files
authored
Merge pull request #1604 from entireio/serialized-plotting-thompson
fix(cli): Ctrl-C escapes shell loops and aborts cleanly
2 parents 1f3c803 + 54a30ec commit 6a41271

4 files changed

Lines changed: 189 additions & 9 deletions

File tree

cmd/entire/main.go

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import (
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, "\nInterrupting… press Ctrl-C again to force quit.")
51+
} else {
52+
fmt.Fprintln(os.Stderr, "\nReceived 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

cmd/entire/main_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"syscall"
6+
"testing"
7+
)
8+
9+
// nonNumericSignal is an os.Signal that isn't a syscall.Signal, exercising
10+
// exitCodeForSignal's fallback branch.
11+
type nonNumericSignal struct{}
12+
13+
func (nonNumericSignal) String() string { return "non-numeric" }
14+
func (nonNumericSignal) Signal() {}
15+
16+
// TestExitCodeForSignal locks the conventional 128+signum mapping the
17+
// Ctrl-C/SIGTERM fix relies on, so a future "simplification" back to a
18+
// hardcoded 130 can't silently regress SIGTERM's 143.
19+
func TestExitCodeForSignal(t *testing.T) {
20+
t.Parallel()
21+
22+
tests := []struct {
23+
name string
24+
sig os.Signal
25+
want int
26+
}{
27+
{"SIGINT", os.Interrupt, 130},
28+
{"SIGTERM", syscall.SIGTERM, 143},
29+
{"non-numeric signal falls back to 130", nonNumericSignal{}, 130},
30+
}
31+
for _, tc := range tests {
32+
t.Run(tc.name, func(t *testing.T) {
33+
t.Parallel()
34+
if got := exitCodeForSignal(tc.sig); got != tc.want {
35+
t.Errorf("exitCodeForSignal(%v) = %d, want %d", tc.sig, got, tc.want)
36+
}
37+
})
38+
}
39+
}

internal/entireclient/tokenstore/keyring_timeout.go

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"os"
7+
"os/signal"
78
"runtime"
89
"time"
910
)
@@ -36,15 +37,34 @@ func keyringTimeout() time.Duration {
3637
}
3738

3839
// callKeyringWithTimeout runs fn in a goroutine and returns its result,
39-
// or a descriptive error if the configured keyring timeout elapses
40-
// first. The goroutine continues running — a blocked D-Bus syscall
41-
// can't be cancelled from Go — and its eventual result is discarded.
42-
// The buffered result channel keeps the goroutine from leaking forever
43-
// waiting to publish into a receiver that's already gone. fn's own
44-
// error (including ErrNotFound) propagates unchanged on the fast path;
45-
// only the timeout branch wraps.
40+
// or a descriptive error if the configured keyring timeout elapses or the
41+
// user interrupts (Ctrl-C) first. The goroutine continues running — a
42+
// blocked D-Bus syscall / Keychain subprocess can't be cancelled from Go —
43+
// and its eventual result is discarded. The buffered result channel keeps
44+
// the goroutine from leaking forever waiting to publish into a receiver
45+
// that's already gone. fn's own error (including ErrNotFound) propagates
46+
// unchanged on the fast path; only the timeout and interrupt branches wrap.
47+
//
48+
// It listens for SIGINT for the duration of the call so a Ctrl-C unblocks a
49+
// stuck keyring read *now* rather than after the full timeout. This is the
50+
// only cancellation lever available here: the credential store is reached
51+
// through auth-go's Store interface (LoadTokens/SaveTokens), which carries
52+
// no context.Context, so a per-request context can't be threaded down to
53+
// this point. signal.Notify fans a signal out to every registered channel,
54+
// so the process's own handler (which cancels the root context) still runs
55+
// — this is an additional listener scoped to the keyring call.
4656
func callKeyringWithTimeout(op string, fn func() (string, error)) (string, error) {
47-
ctx, cancel := context.WithTimeout(context.Background(), keyringTimeout())
57+
sigCh := make(chan os.Signal, 1)
58+
signal.Notify(sigCh, os.Interrupt)
59+
defer signal.Stop(sigCh)
60+
return callKeyringWithInterrupt(op, keyringTimeout(), fn, sigCh)
61+
}
62+
63+
// callKeyringWithInterrupt is the testable core of callKeyringWithTimeout:
64+
// the interrupt source is injected so tests can exercise the Ctrl-C branch
65+
// without sending real signals to the test process.
66+
func callKeyringWithInterrupt(op string, timeout time.Duration, fn func() (string, error), interrupt <-chan os.Signal) (string, error) {
67+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
4868
defer cancel()
4969

5070
type result struct {
@@ -59,6 +79,10 @@ func callKeyringWithTimeout(op string, fn func() (string, error)) (string, error
5979
select {
6080
case r := <-ch:
6181
return r.val, r.err
82+
case <-interrupt:
83+
// Wrap context.Canceled so the abort flows into the CLI's silent
84+
// "user aborted" exit path rather than printing as a keyring failure.
85+
return "", fmt.Errorf("%s interrupted: %w", op, context.Canceled)
6286
case <-ctx.Done():
6387
return "", fmt.Errorf(
6488
"%s timed out: OS keyring (%s) appears unavailable; set %s to a longer duration to wait further: %w",

internal/entireclient/tokenstore/keyring_timeout_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package tokenstore
33
import (
44
"context"
55
"errors"
6+
"os"
67
"strings"
78
"testing"
89
"time"
@@ -76,6 +77,38 @@ func TestCallKeyringWithTimeout_DeadlineExceeded(t *testing.T) {
7677
}
7778
}
7879

80+
// A Ctrl-C must unblock a stuck keyring call immediately — well before the
81+
// timeout — and surface as a context.Canceled so the CLI treats it as a user
82+
// abort rather than a keyring failure.
83+
func TestCallKeyringWithInterrupt_AbortsOnSignal(t *testing.T) {
84+
t.Parallel()
85+
86+
interrupt := make(chan os.Signal, 1)
87+
started := make(chan struct{})
88+
start := time.Now()
89+
go func() {
90+
<-started
91+
interrupt <- os.Interrupt
92+
}()
93+
94+
_, err := callKeyringWithInterrupt("get", 10*time.Second, func() (string, error) {
95+
close(started)
96+
time.Sleep(10 * time.Second) // never completes within the test
97+
return "should not be returned", nil
98+
}, interrupt)
99+
elapsed := time.Since(start)
100+
101+
if !errors.Is(err, context.Canceled) {
102+
t.Fatalf("want context.Canceled wrapped, got %v", err)
103+
}
104+
if elapsed > 2*time.Second {
105+
t.Fatalf("interrupt did not return promptly: elapsed=%s", elapsed)
106+
}
107+
if !strings.Contains(err.Error(), "interrupted") {
108+
t.Errorf("error %q should mention it was interrupted", err.Error())
109+
}
110+
}
111+
79112
func TestKeyringTimeout_DefaultWhenUnset(t *testing.T) {
80113
t.Setenv(keyringTimeoutEnvVar, "")
81114

0 commit comments

Comments
 (0)