Skip to content

Commit 875d597

Browse files
authored
Merge pull request #1625 from entireio/soph/keyring-interrupt-race-followup
fix(cli): close keyring-interrupt race in Ctrl-C signal-abort
2 parents 9e20aea + 464bf4d commit 875d597

6 files changed

Lines changed: 279 additions & 26 deletions

File tree

cmd/entire/main.go

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ import (
88
"os/signal"
99
"runtime"
1010
"strings"
11-
"sync/atomic"
1211
"syscall"
1312
"time"
1413

1514
"github.com/entireio/cli/cmd/entire/cli"
1615
"github.com/entireio/cli/cmd/entire/cli/api"
1716
"github.com/entireio/cli/cmd/entire/cli/versioninfo"
17+
"github.com/entireio/cli/internal/procsignal"
1818
"github.com/spf13/cobra"
1919
)
2020

@@ -45,7 +45,7 @@ func main() {
4545
// re-raises that same signal — a SIGTERM (from a supervisor /
4646
// container stop) must exit 143, not masquerade as a SIGINT 130.
4747
sig := <-sigChan
48-
caughtSignal.Store(sig)
48+
procsignal.Store(sig)
4949
if sig == os.Interrupt {
5050
fmt.Fprintln(os.Stderr, "\nInterrupting… press Ctrl-C again to force quit.")
5151
} else {
@@ -89,20 +89,25 @@ func main() {
8989
var silent *cli.SilentError
9090

9191
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.
92+
case errors.Is(err, context.Canceled) && procsignal.Load() != nil:
93+
// A signal cancelled the root context (our handler fired) or a
94+
// keyring read was aborted by Ctrl-C. Don't dump the raw
95+
// transport/keyring cancellation string ("...: context canceled",
96+
// "read access token: signal: interrupt") as if it were a failure
97+
// — die quietly by re-raising the signal that triggered it (see
98+
// dieFromSignal) so an enclosing `while ...; do entire; done` loop
99+
// actually breaks on a single Ctrl-C, and a SIGTERM shutdown still
100+
// exits 143.
100101
//
101-
// We gate on the handler having fired rather than on the error
102-
// type alone: a context.Canceled that arose without a signal
102+
// We gate on a signal having been recorded rather than on the
103+
// error type alone: a context.Canceled that arose without a signal
103104
// (e.g. an internally-cancelled sub-context) is a genuine error
104105
// and must fall through to normal reporting, not masquerade as a
105106
// user abort (which would also wrongly break an enclosing loop).
107+
// procsignal is the shared source of truth written both by the
108+
// handler above and by the keyring interrupt path; the latter
109+
// records the signal on this same goroutine before returning, so
110+
// this Load never races that write.
106111
cancel()
107112
dieFromSignal(terminatingSignal())
108113
case errors.As(err, &silent):
@@ -130,19 +135,14 @@ func main() {
130135
cancel() // Cleanup on successful exit
131136
}
132137

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-
138138
// 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).
139+
// defaulting to SIGINT when the cancellation came from something other than a
140+
// recorded terminating signal (so a stray context.Canceled still exits 130).
141+
// The recorded signal lives in the shared procsignal package, written by the
142+
// handler goroutine (SIGINT/SIGTERM) and by the keyring interrupt path (SIGINT).
141143
func terminatingSignal() os.Signal {
142-
if v := caughtSignal.Load(); v != nil {
143-
if s, ok := v.(os.Signal); ok {
144-
return s
145-
}
144+
if s := procsignal.Load(); s != nil {
145+
return s
146146
}
147147
return os.Interrupt
148148
}

cmd/entire/main_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,93 @@
11
package main
22

33
import (
4+
"errors"
45
"os"
6+
"os/exec"
7+
"runtime"
58
"syscall"
69
"testing"
710
)
811

12+
// dieFromSignalEnvVar, when set on a re-executed test binary, makes TestMain
13+
// invoke dieFromSignal with the named signal instead of running the test suite.
14+
// The parent process then inspects how the child died. This is how
15+
// TestDieFromSignal_TerminatesBySignal exercises real process death without
16+
// killing the test runner itself.
17+
const dieFromSignalEnvVar = "ENTIRE_TEST_DIE_FROM_SIGNAL"
18+
19+
func TestMain(m *testing.M) {
20+
// Child mode: exercise dieFromSignal for the named signal and let it
21+
// terminate this process. dieFromSignal only returns if the re-raise
22+
// couldn't be delivered, so the os.Exit fallback mirrors it.
23+
if name := os.Getenv(dieFromSignalEnvVar); name != "" {
24+
sig := os.Interrupt
25+
if name == "TERM" {
26+
sig = syscall.SIGTERM
27+
}
28+
dieFromSignal(sig)
29+
os.Exit(exitCodeForSignal(sig))
30+
}
31+
os.Exit(m.Run())
32+
}
33+
934
// nonNumericSignal is an os.Signal that isn't a syscall.Signal, exercising
1035
// exitCodeForSignal's fallback branch.
1136
type nonNumericSignal struct{}
1237

1338
func (nonNumericSignal) String() string { return "non-numeric" }
1439
func (nonNumericSignal) Signal() {}
1540

41+
// TestDieFromSignal_TerminatesBySignal is the regression guard for the headline
42+
// behavior: an enclosing `while true; do entire …; done` loop only breaks when
43+
// the process is *killed by* the signal (WIFSIGNALED), not when it exits
44+
// normally with code 130. A "simplification" of dieFromSignal back to a plain
45+
// os.Exit(130) would leave the exit code looking right while silently breaking
46+
// loop-escape — this test catches exactly that by re-executing the test binary
47+
// in child mode and asserting it died by the signal.
48+
func TestDieFromSignal_TerminatesBySignal(t *testing.T) {
49+
t.Parallel()
50+
if runtime.GOOS == "windows" {
51+
t.Skip("signal-to-self / WIFSIGNALED semantics do not apply on Windows")
52+
}
53+
54+
tests := []struct {
55+
name string
56+
env string
57+
want syscall.Signal
58+
}{
59+
{"SIGINT", "INT", syscall.SIGINT},
60+
{"SIGTERM", "TERM", syscall.SIGTERM},
61+
}
62+
for _, tc := range tests {
63+
t.Run(tc.name, func(t *testing.T) {
64+
t.Parallel()
65+
66+
// -test.run=^$ matches no test; TestMain's child branch runs before
67+
// m.Run() and terminates the process, so no test actually executes.
68+
cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^$")
69+
cmd.Env = append(os.Environ(), dieFromSignalEnvVar+"="+tc.env)
70+
71+
err := cmd.Run()
72+
73+
var exitErr *exec.ExitError
74+
if !errors.As(err, &exitErr) {
75+
t.Fatalf("child did not exit with an error status; err=%v (expected death by signal)", err)
76+
}
77+
ws, ok := exitErr.Sys().(syscall.WaitStatus)
78+
if !ok {
79+
t.Fatalf("no syscall.WaitStatus available: %T", exitErr.Sys())
80+
}
81+
if !ws.Signaled() {
82+
t.Fatalf("child exited normally with code %d; want death by signal %v — dieFromSignal must re-raise, not os.Exit", ws.ExitStatus(), tc.want)
83+
}
84+
if ws.Signal() != tc.want {
85+
t.Fatalf("child killed by %v, want %v", ws.Signal(), tc.want)
86+
}
87+
})
88+
}
89+
}
90+
1691
// TestExitCodeForSignal locks the conventional 128+signum mapping the
1792
// Ctrl-C/SIGTERM fix relies on, so a future "simplification" back to a
1893
// hardcoded 130 can't silently regress SIGTERM's 143.

internal/entireclient/tokenstore/keyring_timeout.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ package tokenstore
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"os"
78
"os/signal"
89
"runtime"
910
"time"
11+
12+
"github.com/entireio/cli/internal/procsignal"
1013
)
1114

1215
// defaultKeyringTimeout caps how long every OS keyring call may take.
@@ -57,7 +60,21 @@ func callKeyringWithTimeout(op string, fn func() (string, error)) (string, error
5760
sigCh := make(chan os.Signal, 1)
5861
signal.Notify(sigCh, os.Interrupt)
5962
defer signal.Stop(sigCh)
60-
return callKeyringWithInterrupt(op, keyringTimeout(), fn, sigCh)
63+
return recordInterruptSignal(callKeyringWithInterrupt(op, keyringTimeout(), fn, sigCh))
64+
}
65+
66+
// recordInterruptSignal records the shared "we were signalled" marker when the
67+
// keyring call was aborted by a Ctrl-C (a wrapped context.Canceled from the
68+
// interrupt branch below). It runs on the goroutine that unwinds to the CLI's
69+
// top-level signal-abort gate, so the store is ordered before that gate reads
70+
// procsignal — closing the race against the asynchronous signal handler that
71+
// also received the SIGINT. A timeout wraps context.DeadlineExceeded, not
72+
// Canceled, so it is left untouched.
73+
func recordInterruptSignal(val string, err error) (string, error) {
74+
if errors.Is(err, context.Canceled) {
75+
procsignal.Store(os.Interrupt)
76+
}
77+
return val, err
6178
}
6279

6380
// callKeyringWithInterrupt is the testable core of callKeyringWithTimeout:

internal/entireclient/tokenstore/keyring_timeout_test.go

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@ package tokenstore
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"os"
78
"strings"
89
"testing"
910
"time"
11+
12+
"github.com/entireio/cli/internal/procsignal"
1013
)
1114

15+
// notReturnedSentinel is the value fn returns when the test expects the
16+
// interrupt/timeout branch to win the select, so this value must never surface.
17+
const notReturnedSentinel = "should not be returned"
18+
1219
func TestCallKeyringWithTimeout_ReturnsValueWhenFast(t *testing.T) {
1320
t.Parallel()
1421

@@ -55,7 +62,7 @@ func TestCallKeyringWithTimeout_DeadlineExceeded(t *testing.T) {
5562
start := time.Now()
5663
_, err := callKeyringWithTimeout("get", func() (string, error) {
5764
time.Sleep(5 * time.Second)
58-
return "should not be returned", nil
65+
return notReturnedSentinel, nil
5966
})
6067
elapsed := time.Since(start)
6168

@@ -94,7 +101,7 @@ func TestCallKeyringWithInterrupt_AbortsOnSignal(t *testing.T) {
94101
_, err := callKeyringWithInterrupt("get", 10*time.Second, func() (string, error) {
95102
close(started)
96103
time.Sleep(10 * time.Second) // never completes within the test
97-
return "should not be returned", nil
104+
return notReturnedSentinel, nil
98105
}, interrupt)
99106
elapsed := time.Since(start)
100107

@@ -109,6 +116,66 @@ func TestCallKeyringWithInterrupt_AbortsOnSignal(t *testing.T) {
109116
}
110117
}
111118

119+
// recordInterruptSignal must record a shared SIGINT marker for a Ctrl-C abort
120+
// (wrapped context.Canceled) so the CLI's signal-abort gate recognizes it
121+
// without racing the async top-level handler — but must leave the marker
122+
// untouched for a timeout or any non-abort error. This test mutates the
123+
// process-global procsignal state, so it can't run in parallel.
124+
func TestRecordInterruptSignal(t *testing.T) {
125+
t.Run("records SIGINT on interrupt abort", func(t *testing.T) {
126+
procsignal.Reset()
127+
t.Cleanup(procsignal.Reset)
128+
129+
val, err := recordInterruptSignal(callKeyringWithInterruptResult())
130+
if val != "" || !errors.Is(err, context.Canceled) {
131+
t.Fatalf("passthrough changed value/err: val=%q err=%v", val, err)
132+
}
133+
if got := procsignal.Load(); got != os.Interrupt {
134+
t.Fatalf("procsignal.Load() = %v, want SIGINT", got)
135+
}
136+
})
137+
138+
t.Run("leaves marker unset on timeout", func(t *testing.T) {
139+
procsignal.Reset()
140+
t.Cleanup(procsignal.Reset)
141+
142+
timeoutErr := fmt.Errorf("get timed out: %w", context.DeadlineExceeded)
143+
if _, err := recordInterruptSignal("", timeoutErr); !errors.Is(err, context.DeadlineExceeded) {
144+
t.Fatalf("passthrough changed err: %v", err)
145+
}
146+
if got := procsignal.Load(); got != nil {
147+
t.Fatalf("procsignal.Load() = %v, want nil (timeout is not a signal abort)", got)
148+
}
149+
})
150+
151+
t.Run("leaves marker unset on success", func(t *testing.T) {
152+
procsignal.Reset()
153+
t.Cleanup(procsignal.Reset)
154+
155+
if _, err := recordInterruptSignal("token", nil); err != nil {
156+
t.Fatalf("passthrough changed err: %v", err)
157+
}
158+
if got := procsignal.Load(); got != nil {
159+
t.Fatalf("procsignal.Load() = %v, want nil", got)
160+
}
161+
})
162+
}
163+
164+
// callKeyringWithInterruptResult produces the exact (val, err) shape the
165+
// interrupt branch returns, so the test exercises recordInterruptSignal against
166+
// the real wrapped error rather than a hand-rolled one.
167+
func callKeyringWithInterruptResult() (string, error) {
168+
interrupt := make(chan os.Signal, 1)
169+
interrupt <- os.Interrupt
170+
return callKeyringWithInterrupt("get", time.Second, func() (string, error) {
171+
// The pre-loaded interrupt wins the select immediately; this brief
172+
// sleep just keeps fn from racing it, then the goroutine exits into
173+
// the buffered result channel (no leak).
174+
time.Sleep(50 * time.Millisecond)
175+
return notReturnedSentinel, nil
176+
}, interrupt)
177+
}
178+
112179
func TestKeyringTimeout_DefaultWhenUnset(t *testing.T) {
113180
t.Setenv(keyringTimeoutEnvVar, "")
114181

internal/procsignal/procsignal.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Package procsignal records the OS signal, if any, that initiated process
2+
// shutdown. It gives the CLI a single source of truth for "were we signalled?"
3+
// shared by the two places that can observe a terminating signal:
4+
//
5+
// - the top-level signal handler in cmd/entire, which cancels the root
6+
// context on SIGINT/SIGTERM, and
7+
// - the keyring interrupt path in internal/entireclient/tokenstore, which
8+
// detects Ctrl-C via its own signal.Notify listener so a stuck keyring
9+
// read unblocks immediately.
10+
//
11+
// Before this package the two mechanisms were uncoordinated: the keyring path
12+
// returned a context.Canceled error while the top-level handler stored the
13+
// caught signal asynchronously on a different goroutine, so the CLI's
14+
// signal-abort gate could read the store before it was set and misreport a
15+
// user abort as a failure (and fail to break an enclosing shell loop).
16+
// Recording the signal here — on the same goroutine that unwinds to the gate —
17+
// removes that race.
18+
//
19+
// Tech debt: this shared global exists only because auth-go's Store interface
20+
// (LoadTokens/SaveTokens) carries no context.Context, so the keyring path can't
21+
// ride the root context and instead detects Ctrl-C via its own signal listener.
22+
// Once that interface accepts a context, keyring cancellation can flow from the
23+
// root context like everything else, the tokenstore signal listener can go
24+
// away, and this package with it.
25+
package procsignal
26+
27+
import (
28+
"os"
29+
"sync/atomic"
30+
)
31+
32+
// holder wraps the stored signal so atomic.Value always observes one concrete
33+
// type. Storing differing concrete types (or nil) into an atomic.Value panics;
34+
// wrapping avoids both.
35+
type holder struct{ sig os.Signal }
36+
37+
var caught atomic.Value // stores holder
38+
39+
// Store records sig as the signal that initiated shutdown. Safe for concurrent
40+
// use; last writer wins, which is fine because every caller stores a genuine
41+
// terminating signal.
42+
func Store(sig os.Signal) {
43+
caught.Store(holder{sig: sig})
44+
}
45+
46+
// Load returns the recorded terminating signal, or nil if none was recorded.
47+
func Load() os.Signal {
48+
if h, ok := caught.Load().(holder); ok {
49+
return h.sig
50+
}
51+
return nil
52+
}
53+
54+
// Reset clears the recorded signal. It exists for tests that need a clean
55+
// slate; production code never clears it (the process is on its way out).
56+
func Reset() {
57+
caught.Store(holder{})
58+
}

0 commit comments

Comments
 (0)