tokenstore: bound OS keyring calls with a timeout · Entire
tokenstore: bound OS keyring calls with a timeout
613cdaf→main
The keyring-timeout shim deleted in 37de13a6d only ever wrapped the legacy auth.Store; the surviving tokenstore keyringStore called keyring.Get/Set/Delete with no deadline, even on main. The legacy store's dual-write was the only thing that surfaced a hung keyring (via its 5s timeout) during login — with it gone, login/logout/status can block forever on a headless Linux box with no Secret Service daemon, a suppressed Keychain prompt, or a stuck Credential Manager.
Move the timeout down to where the raw keyring calls now live: every keyringStore op runs in a goroutine bounded by ENTIRE_KEYRING_TIMEOUT (default 5s). The inner error — including ErrNotFound — propagates unchanged on the fast path; only the timeout branch wraps, naming the platform's keyring backend and the override env var. The file backend is left untouched: it can't hang on a daemon.
Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Sessions
589410b3edffView transcript
Changes
3
internal/entireclient/tokenstore
Akeyring_timeout.go+86
Akeyring_timeout_test.go+119
Mtokenstore.go+16/-7
package tokenstore
import (
"context"
"fmt"
"os"
"runtime"
"time"
)
// defaultKeyringTimeout caps how long every OS keyring call may take.
// The underlying keyring API (Secret Service on Linux, Keychain on
// macOS, Credential Manager on Windows) can block indefinitely when no
// provider is reachable — a headless SSH/container/WSL session, a
// suppressed Keychain prompt, a stuck Credential Manager — and that
// freezes the CLI. 5s is comfortably longer than any healthy
// round-trip while still surfacing the hang to the user quickly.
const defaultKeyringTimeout = 5 * time.Second
// keyringTimeoutEnvVar overrides defaultKeyringTimeout. Accepts any
// time.ParseDuration string; invalid or non-positive values fall back
// to the default. Useful on slow keyrings or to extend the wait on a
// system where the secret service is just sluggish.
const keyringTimeoutEnvVar = "ENTIRE_KEYRING_TIMEOUT"
func keyringTimeout() time.Duration {
v := os.Getenv(keyringTimeoutEnvVar)
if v == "" {
return defaultKeyringTimeout
}
d, err := time.ParseDuration(v)
if err != nil || d <= 0 {
return defaultKeyringTimeout
}
return d
}
// 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.
func callKeyringWithTimeout(op string, fn func() (string, error)) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), keyringTimeout())
defer cancel()
type result struct {
val string
err error
}
ch := make(chan result, 1)
go func() {
v, err := fn()
ch <- result{val: v, err: err}
}()
select {
case r := <-ch:
return r.val, r.err
case <-ctx.Done():
return "", fmt.Errorf(
"%s timed out: OS keyring (%s) appears unavailable; set %s to a longer duration to wait further: %w",
op, keyringProviderName(), keyringTimeoutEnvVar, ctx.Err(),
)
}
}
// keyringProviderName returns the human name of the OS keyring backend
// for the current platform, so the timeout error can point the user at
// the specific service that's likely stuck (Keychain on macOS,
// Credential Manager on Windows, Secret Service on Linux/BSD). The
// fallback for unrecognised GOOS is the generic "OS keyring".
func keyringProviderName() string {
switch runtime.GOOS {
case "darwin":
return "macOS Keychain"
case "windows":
return "Windows Credential Manager"
case "linux", "freebsd", "openbsd", "netbsd", "dragonfly":
return "Secret Service (D-Bus)"
default:
return "OS keyring"
}
}
package tokenstore
import (
"context"
"errors"
"strings"
"testing"
"time"
)
func TestCallKeyringWithTimeout_ReturnsValueWhenFast(t *testing.T) {
t.Parallel()
got, err := callKeyringWithTimeout("get", func() (string, error) {
return "token", nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "token" {
t.Fatalf("got = %q, want %q", got, "token")
}
}
func TestCallKeyringWithTimeout_PropagatesInnerError(t *testing.T) {
t.Parallel()
sentinel := errors.New("backend exploded")
_, err := callKeyringWithTimeout("get", func() (string, error) {
return "", sentinel
})
if !errors.Is(err, sentinel) {
t.Fatalf("got %v, want %v wrapped", err, sentinel)
}
}
// A missing-credential error must reach the caller unchanged so the
// errors.Is(err, ErrNotFound) checks scattered through the auth package
// keep working through the timeout wrapper.
func TestCallKeyringWithTimeout_PropagatesNotFound(t *testing.T) {
t.Parallel()
_, err := callKeyringWithTimeout("get", func() (string, error) {
return "", ErrNotFound
})
if !errors.Is(err, ErrNotFound) {
t.Fatalf("got %v, want ErrNotFound", err)
}
}
func TestCallKeyringWithTimeout_DeadlineExceeded(t *testing.T) {
t.Setenv(keyringTimeoutEnvVar, "50ms")
start := time.Now()
_, err := callKeyringWithTimeout("get", func() (string, error) {
time.Sleep(5 * time.Second)
return "should not be returned", nil
})
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected timeout error, got nil")
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("want DeadlineExceeded wrapped, got %v", err)
}
if elapsed > 2*time.Second {
t.Fatalf("call did not return promptly after timeout: elapsed=%s", elapsed)
}
msg := err.Error()
for _, want := range []string{"get", "OS keyring", keyringTimeoutEnvVar} {
if !strings.Contains(msg, want) {
t.Errorf("timeout error %q missing %q", msg, want)
}
}
}
func TestKeyringTimeout_DefaultWhenUnset(t *testing.T) {
t.Setenv(keyringTimeoutEnvVar, "")
if got := keyringTimeout(); got != defaultKeyringTimeout {
t.Fatalf("got %v, want default %v", got, defaultKeyringTimeout)
}
}
func TestKeyringTimeout_HonoursEnvOverride(t *testing.T) {
t.Setenv(keyringTimeoutEnvVar, "150ms")
if got := keyringTimeout(); got != 150*time.Millisecond {
t.Fatalf("got %v, want 150ms", got)
}
}
func TestKeyringTimeout_IgnoresInvalidEnvValue(t *testing.T) {
t.Setenv(keyringTimeoutEnvVar, "not-a-duration")
if got := keyringTimeout(); got != defaultKeyringTimeout {
t.Fatalf("got %v, want default %v", got, defaultKeyringTimeout)
}
}
func TestKeyringTimeout_IgnoresNonPositiveValue(t *testing.T) {
t.Setenv(keyringTimeoutEnvVar, "0s")
if got := keyringTimeout(); got != defaultKeyringTimeout {
t.Fatalf("got %v, want default %v", got, defaultKeyringTimeout)
}
}
// keyringProviderName must always name something the timeout error can
// point at, including on unrecognised platforms.
func TestKeyringProviderName_NonEmpty(t *testing.T) {
t.Parallel()
if keyringProviderName() == "" {
t.Fatal("keyringProviderName returned empty string")
}
}
// keyringStore delegates to the OS keyring.
// keyringStore delegates to the OS keyring. Every call is bounded by
// callKeyringWithTimeout: the underlying provider (Secret Service,
// Keychain, Credential Manager) can block indefinitely when no daemon
// is reachable, and an unbounded keyring call freezes the whole CLI.
type keyringStore struct{}
func (keyringStore) Get(service, user string) (string, error) {
//nolint:wrapcheck // thin wrapper, callers handle errors
return keyring.Get(service, user)
// keyring.ErrNotFound propagates unchanged; only a timeout wraps.
return callKeyringWithTimeout("get", func() (string, error) {
return keyring.Get(service, user)
})
}
func (keyringStore) Set(service, user, password string) error {
//nolint:wrapcheck // thin wrapper, callers handle errors
return keyring.Set(service, user, password)
_, err := callKeyringWithTimeout("set", func() (string, error) {
return "", keyring.Set(service, user, password)
})
return err
}
func (keyringStore) Delete(service, user string) error {
//nolint:wrapcheck // thin wrapper, callers handle errors
return keyring.Delete(service, user)
_, err := callKeyringWithTimeout("delete", func() (string, error) {
return "", keyring.Delete(service, user)
})
return err
}
Minternal/entireclient/tokenstore/tokenstore.go+16/-7