fix(tokenstore): abort a stuck keyring read on Ctrl-C · Entire
fix(tokenstore): abort a stuck keyring read on Ctrl-C
b3d0b68→main·
toothbrush·2w ago·2 files·+65 added/-8 removed
The OS keyring call ran on context.Background(), so a Ctrl-C during a slow/blocked read (Keychain subprocess, or a headless box with no keyring daemon) left the user waiting out the full 5s timeout — or hanging.
Listen for SIGINT for the duration of the call and return early wrapping context.Canceled (which flows into the CLI's silent user-abort exit). signal.Notify fans out to every channel, so the process's own handler still cancels the root context. A per-request context can't be threaded here: the store is reached via auth-go's Store interface, which carries no context.Context.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Sessions
32928d28c989View transcript
[?
Fix Ctrl-C Signal Handling in CLIClaude Code·Opus 4.8[1m]·4 steps](/content/gh/entireio/cli/session/f47f79d0-db3e-42d1-8b5b-d644e5b380c1#timeline-32928d28c989/index.html)
Changes
2
internal/entireclient/tokenstore
Mkeyring_timeout.go+32/-8
Mkeyring_timeout_test.go+33
3 unmodified lines
4
5
6
7
8
9
10
26 unmodified lines
37
38
39
39
40
41
42
43
44
45
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
47
57
58
59
60
61
62
63
64
65
66
67
68
69
70
8 unmodified lines
79
80
81
82
83
84
85
86
87
88
3 unmodified lines
"context"
"fmt"
"os"
"os/signal"
"runtime"
"time"
)
26 unmodified lines
}
// callKeyringWithTimeout runs fn in a goroutine and returns its result,
// or a descriptive error if the configured keyring timeout elapses
// first. The goroutine continues running — a blocked D-Bus syscall
// can't be cancelled from Go — and its eventual result is discarded.
// The buffered result channel keeps the goroutine from leaking forever
// waiting to publish into a receiver that's already gone. fn's own
// error (including ErrNotFound) propagates unchanged on the fast path;
// only the timeout branch wraps.
// or a descriptive error if the configured keyring timeout elapses or the
// user interrupts (Ctrl-C) first. The goroutine continues running — a
// blocked D-Bus syscall / Keychain subprocess can't be cancelled from Go —
// and its eventual result is discarded. The buffered result channel keeps
// the goroutine from leaking forever waiting to publish into a receiver
// that's already gone. fn's own error (including ErrNotFound) propagates
// unchanged on the fast path; only the timeout and interrupt branches wrap.
//
// It listens for SIGINT for the duration of the call so a Ctrl-C unblocks a
// stuck keyring read *now* rather than after the full timeout. This is the
// only cancellation lever available here: the credential store is reached
// through auth-go's Store interface (LoadTokens/SaveTokens), which carries
// no context.Context, so a per-request context can't be threaded down to
// this point. signal.Notify fans a signal out to every registered channel,
// so the process's own handler (which cancels the root context) still runs
// — this is an additional listener scoped to the keyring call.
func callKeyringWithTimeout(op string, fn func() (string, error)) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), keyringTimeout())
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt)
defer signal.Stop(sigCh)
return callKeyringWithInterrupt(op, keyringTimeout(), fn, sigCh)
}
// callKeyringWithInterrupt is the testable core of callKeyringWithTimeout:
// the interrupt source is injected so tests can exercise the Ctrl-C branch
// without sending real signals to the test process.
func callKeyringWithInterrupt(op string, timeout time.Duration, fn func() (string, error), interrupt <-chan os.Signal) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
type result struct {
8 unmodified lines
select {
case r := <-ch:
return r.val, r.err
case <-interrupt:
// Wrap context.Canceled so the abort flows into the CLI's silent
// "user aborted" exit path rather than printing as a keyring failure.
return "", fmt.Errorf("%s interrupted: %w", op, context.Canceled)
case <-ctx.Done():
return "", fmt.Errorf(
"%s timed out: OS keyring (%s) appears unavailable; set %s to a longer duration to wait further: %w",
Minternal/entireclient/tokenstore/keyring_timeout.go+32/-8
2 unmodified lines
3
4
5
6
7
8
9
67 unmodified lines
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
2 unmodified lines
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
67 unmodified lines
}
// A Ctrl-C must unblock a stuck keyring call immediately — well before the
// timeout — and surface as a context.Canceled so the CLI treats it as a user
// abort rather than a keyring failure.
func TestCallKeyringWithInterrupt_AbortsOnSignal(t *testing.T) {
t.Parallel()
interrupt := make(chan os.Signal, 1)
started := make(chan struct{})
start := time.Now()
go func() {
<-started
interrupt <- os.Interrupt
}()
_, err := callKeyringWithInterrupt("get", 10*time.Second, func() (string, error) {
close(started)
time.Sleep(10 * time.Second) // never completes within the test
return "should not be returned", nil
}, interrupt)
elapsed := time.Since(start)
if !errors.Is(err, context.Canceled) {
t.Fatalf("want context.Canceled wrapped, got %v", err)
}
if elapsed > 2*time.Second {
t.Fatalf("interrupt did not return promptly: elapsed=%s", elapsed)
}
if !strings.Contains(err.Error(), "interrupted") {
t.Errorf("error %q should mention it was interrupted", err.Error())
}
}
func TestKeyringTimeout_DefaultWhenUnset(t *testing.T) {
t.Setenv(keyringTimeoutEnvVar, "")