fix(cli): gate silent-abort on signal handler firing; test exit codes · Entire
fix(cli): gate silent-abort on signal handler firing; test exit codes
54a30ec→main·toothbrush·2w ago·2 files·+51 added/-6 removed
Addresses two trail findings:
- Matching context.Canceled at the top level was too broad: an internally-cancelled sub-context would have silently re-raised SIGINT (exit 130) and broken an enclosing loop even without a Ctrl-C. Gate the abort path on caughtSignal being set, so a signal-less context.Canceled falls through to normal error reporting.
- Add a table test for exitCodeForSignal locking SIGINT→130, SIGTERM→143 (and the non-numeric fallback), guarding the mapping the fix relies on.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Changes
2
cmd/entire
Mmain.go+12/-6
Amain_test.go+39
88 unmodified lines
89
90
91
92
93
94
95
96
97
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
88 unmodified lines
var silent *cli.SilentError
switch {
case errors.Is(err, context.Canceled):
// Aborted (a signal 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 the signal that
// triggered it (see dieFromSignal) so an enclosing
case errors.Is(err, context.Canceled) && caughtSignal.Load() != nil:
// A signal cancelled the root context (our handler fired). 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 the signal
// that triggered it (see dieFromSignal) so an enclosing
// `while ...; do entire; done` loop actually breaks on a single
// Ctrl-C, and a SIGTERM shutdown still exits 143.
//
// We gate on the handler having fired rather than on the error
// type alone: a context.Canceled that arose without a signal
// (e.g. an internally-cancelled sub-context) is a genuine error
// and must fall through to normal reporting, not masquerade as a
// user abort (which would also wrongly break an enclosing loop).
cancel()
dieFromSignal(terminatingSignal())
case errors.As(err, &silent):
Mcmd/entire/main.go+12/-6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main
import (
"os"
"syscall"
"testing"
)
// nonNumericSignal is an os.Signal that isn't a syscall.Signal, exercising
// exitCodeForSignal's fallback branch.
type nonNumericSignal struct{}
func (nonNumericSignal) String() string { return "non-numeric" }
func (nonNumericSignal) Signal() {}
// TestExitCodeForSignal locks the conventional 128+signum mapping the
// Ctrl-C/SIGTERM fix relies on, so a future "simplification" back to a
// hardcoded 130 can't silently regress SIGTERM's 143.
func TestExitCodeForSignal(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sig os.Signal
want int
}{
{"SIGINT", os.Interrupt, 130},
{"SIGTERM", syscall.SIGTERM, 143},
{"non-numeric signal falls back to 130", nonNumericSignal{}, 130},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := exitCodeForSignal(tc.sig); got != tc.want {
t.Errorf("exitCodeForSignal(%v) = %d, want %d", tc.sig, got, tc.want)
}
})
}
}
Acmd/entire/main_test.go+39