From c2ab1049e63aac54f9cbf0c27c42fdfebd998294 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 19 Sep 2026 21:27:12 -0400 Subject: [PATCH 1/8] Stop Cable when authentication ends --- go.mod | 2 +- go.sum | 2 + internal/cable/cable.go | 10 ++- internal/cable/cable_test.go | 135 +++++++++++++++++++++++++++++++++++ internal/cmd/watch.go | 10 +++ internal/cmd/watch_test.go | 20 ++++++ 6 files changed, 177 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index a3f5644c..3fe45f2c 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( charm.land/bubbletea/v2 v2.0.9 charm.land/glamour/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/actioncable-go v1.0.0 + github.com/basecamp/actioncable-go v1.0.1-0.20260920012115-d1ee046197ee github.com/basecamp/hey-sdk/go v0.31.1 github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d github.com/charmbracelet/x/ansi v0.11.8 diff --git a/go.sum b/go.sum index 53bb57ac..29235bf0 100644 --- a/go.sum +++ b/go.sum @@ -89,6 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v1.0.0 h1:m8UGFYfBfa/YivQNayc2VoS+oVKLG+ixKzhIzdACWDM= github.com/basecamp/actioncable-go v1.0.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= +github.com/basecamp/actioncable-go v1.0.1-0.20260920012115-d1ee046197ee h1:UokSc0Qnp/2+feQX3gcH5tcw9vRaaPzisstzj2Ml084= +github.com/basecamp/actioncable-go v1.0.1-0.20260920012115-d1ee046197ee/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= github.com/basecamp/hey-sdk/go v0.31.1 h1:dFlCe4LMmLAHgRB05QRHaE26XPPkZVIMp0qCGEanVQU= github.com/basecamp/hey-sdk/go v0.31.1/go.mod h1:eCJOhTLOpi2FCQUXireHZQ7DzRs2TUqvYhRDTfVLoDY= github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4= diff --git a/internal/cable/cable.go b/internal/cable/cable.go index 9618f86d..5e5aa7b3 100644 --- a/internal/cable/cable.go +++ b/internal/cable/cable.go @@ -4,6 +4,7 @@ package cable import ( "context" + "errors" "fmt" "net/http" "net/url" @@ -13,6 +14,7 @@ import ( "github.com/basecamp/actioncable-go" + "github.com/basecamp/hey-cli/internal/apierr" "github.com/basecamp/hey-cli/internal/auth" "github.com/basecamp/hey-cli/internal/version" ) @@ -37,11 +39,12 @@ func Dial(ctx context.Context, baseURL string, authMgr *auth.Manager, options .. return nil, err } - settings := make([]actioncable.Option, 0, 1+len(options)) + settings := make([]actioncable.Option, 0, 2+len(options)) settings = append(settings, actioncable.WithHeaderFunc(func(ctx context.Context) (http.Header, error) { return authHeader(ctx, baseURL, authMgr) })) settings = append(settings, options...) + settings = append(settings, actioncable.WithStopOnError(terminalConnectionError)) opening, giveUp := context.WithTimeout(ctx, openTimeout) defer giveUp() @@ -81,6 +84,11 @@ func URL(baseURL string) (string, error) { return parsed.String(), nil } +func terminalConnectionError(err error) bool { + var classified *apierr.Error + return errors.As(err, &classified) && classified.Code == apierr.CodeAuth +} + func authHeader(ctx context.Context, baseURL string, authMgr *auth.Manager) (http.Header, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) if err != nil { diff --git a/internal/cable/cable_test.go b/internal/cable/cable_test.go index 7bc32a96..1dd6d728 100644 --- a/internal/cable/cable_test.go +++ b/internal/cable/cable_test.go @@ -3,7 +3,10 @@ package cable import ( "context" "errors" + "fmt" + "io" "net/http" + "net/http/httptest" "os" "slices" "strings" @@ -13,6 +16,7 @@ import ( actioncable "github.com/basecamp/actioncable-go" + "github.com/basecamp/hey-cli/internal/apierr" "github.com/basecamp/hey-cli/internal/auth" ) @@ -131,6 +135,137 @@ func TestEveryDialCarriesCurrentCredentials(t *testing.T) { } } +type droppingConn struct { + reads chan []byte + dropped chan struct{} + once sync.Once +} + +func newDroppingConn() *droppingConn { + return &droppingConn{ + reads: make(chan []byte, 1), + dropped: make(chan struct{}), + } +} + +func (c *droppingConn) Subprotocol() string { return actioncable.V1JSON{}.Subprotocol() } + +func (c *droppingConn) Read(ctx context.Context) ([]byte, error) { + select { + case payload := <-c.reads: + return payload, nil + case <-c.dropped: + return nil, io.EOF + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (c *droppingConn) Write(context.Context, []byte) error { return nil } + +func (c *droppingConn) Close() error { + c.once.Do(func() { close(c.dropped) }) + return nil +} + +type singleConnTransport struct { + conn *droppingConn + + mu sync.Mutex + dials int +} + +func (t *singleConnTransport) Dial(context.Context, string, actioncable.DialOptions) (actioncable.Conn, error) { + t.mu.Lock() + defer t.mu.Unlock() + t.dials++ + if t.dials > 1 { + return nil, errors.New("unexpected second transport dial") + } + return t.conn, nil +} + +func TestOnlyAuthenticationFailuresStopReconnects(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {name: "authentication", err: apierr.ErrAuth("signed out"), want: true}, + {name: "rate limit", err: apierr.ErrRateLimit(30), want: false}, + {name: "network", err: apierr.ErrNetwork(io.EOF), want: false}, + {name: "storage", err: errors.New("keyring is locked"), want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := terminalConnectionError(tt.err); got != tt.want { + t.Errorf("terminalConnectionError(%v) = %t, want %t", tt.err, got, tt.want) + } + }) + } +} + +func TestAuthenticationFailureStopsAReconnect(t *testing.T) { + refreshCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth/tokens" { + t.Errorf("path = %q, want /oauth/tokens", r.URL.Path) + } + refreshCalls++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprint(w, `{"error":"invalid_grant"}`) + })) + defer server.Close() + + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_TOKEN", "") + configDir := t.TempDir() + manager := auth.NewManager(server.URL, server.Client(), configDir) + if err := manager.GetStore().Save(manager.CredentialKey(), &auth.Credentials{ + AccessToken: "working-access", + RefreshToken: "dead-refresh", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + }); err != nil { + t.Fatalf("save working credential: %v", err) + } + + conn := newDroppingConn() + conn.reads <- []byte(`{"type":"welcome"}`) + transport := &singleConnTransport{conn: conn} + client, err := Dial(t.Context(), server.URL, manager, + actioncable.WithTransport(transport), + actioncable.WithBackoff(time.Millisecond, time.Millisecond)) + if err != nil { + t.Fatalf("Dial: %v", err) + } + t.Cleanup(func() { _ = client.Close() }) + + replacement := auth.NewManager(server.URL, server.Client(), configDir) + if err := replacement.GetStore().Save(replacement.CredentialKey(), &auth.Credentials{ + AccessToken: "expired-access", + RefreshToken: "dead-refresh", + ExpiresAt: time.Now().Add(-time.Hour).Unix(), + }); err != nil { + t.Fatalf("save expired credential: %v", err) + } + _ = conn.Close() + + select { + case <-client.Done(): + case <-time.After(500 * time.Millisecond): + t.Fatal("client kept reconnecting after authentication failed") + } + var authErr *apierr.Error + if err := client.Err(); !errors.As(err, &authErr) || authErr.Code != apierr.CodeAuth { + t.Fatalf("client error = %v, want authentication failure", err) + } + if refreshCalls != 1 { + t.Errorf("refresh requests = %d, want 1", refreshCalls) + } +} + func TestDialHeaderRereadsStoredCredentials(t *testing.T) { t.Setenv("HEY_NO_KEYRING", "1") t.Setenv("HEY_TOKEN", "") diff --git a/internal/cmd/watch.go b/internal/cmd/watch.go index 37855d21..4f4b1b6d 100644 --- a/internal/cmd/watch.go +++ b/internal/cmd/watch.go @@ -221,6 +221,11 @@ func (c *watchCommand) run(cmd *cobra.Command, args []string) error { // watchDialError tells the two ways a dial fails apart: the server turned the // credentials down, or it couldn't be reached at all. func watchDialError(err error) error { + var classified *apierr.Error + if errors.As(err, &classified) && classified.Code == apierr.CodeAuth { + return classified + } + var disconnect *actioncable.DisconnectError if errors.As(err, &disconnect) && disconnect.Reason == actioncable.ReasonUnauthorized { return apierr.ErrAuth("HEY's cable server turned these credentials down — run `hey auth login` again, or log in with `hey auth login --cookie` if the server doesn't take access tokens on a websocket yet") @@ -478,9 +483,14 @@ func (w *postingsWatch) listen(ctx context.Context, subscription *actioncable.Su // has to hear about rather than exiting quietly. ended is what the subscription says // closed it. func (w *postingsWatch) closedError(ctx context.Context, ended error) error { + var classified *apierr.Error + authenticationFailed := errors.As(ended, &classified) && classified.Code == apierr.CodeAuth + switch { case ctx.Err() != nil: return nil //nolint:nilerr // an interrupt or a --timeout is how a watch is meant to end + case authenticationFailed: + return classified case errors.Is(ended, actioncable.ErrRejected): return apierr.ErrAuth("HEY's cable server turned this subscription down — run `hey auth login` again, or log in with `hey auth login --cookie` if the server doesn't take access tokens on a websocket yet") default: diff --git a/internal/cmd/watch_test.go b/internal/cmd/watch_test.go index 8966601b..969a37ae 100644 --- a/internal/cmd/watch_test.go +++ b/internal/cmd/watch_test.go @@ -728,6 +728,13 @@ func TestWatchClosedSubscriptionIsOnlyFineWhenItWasInterrupted(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "turned this subscription down") { t.Errorf("error = %v, want a rejected subscription reported as an auth failure", err) } + + original := apierr.ErrAuth("the stored session has ended") + err = watch.closedError(context.Background(), original) + var classified *apierr.Error + if !errors.As(err, &classified) || classified.Code != apierr.CodeAuth || classified.Message != original.Message { + t.Errorf("error = %v, want the connection's authentication failure preserved", err) + } } func TestWatchRunsBoundedAsyncScripts(t *testing.T) { @@ -1036,6 +1043,19 @@ func TestWatchLineDescribesTheWatchsOwnNews(t *testing.T) { // so it is not a *hey.Error and the SDK's classifier reads it as a generic API // error — the kind this retries every two minutes, for as long as the shell service // keeps restarting it. +func TestWatchDialErrorPreservesAClientAuthenticationFailure(t *testing.T) { + original := apierr.ErrAuth("the stored session has ended") + err := watchDialError(fmt.Errorf("opening cable: %w", original)) + + var classified *apierr.Error + if !errors.As(err, &classified) || classified.Code != apierr.CodeAuth { + t.Fatalf("error = %v, want the original authentication classification", err) + } + if classified.Message != original.Message { + t.Errorf("message = %q, want %q", classified.Message, original.Message) + } +} + func TestPermanentReadErrorRecognizesACLIAuthFailure(t *testing.T) { tests := []struct { name string From 2347be088f7a7d8f18b34439b7d0cd854bc6ddff Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 19 Sep 2026 21:41:41 -0400 Subject: [PATCH 2/8] Update terminal-error Action Cable revision --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 3fe45f2c..e3280cdd 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( charm.land/bubbletea/v2 v2.0.9 charm.land/glamour/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/actioncable-go v1.0.1-0.20260920012115-d1ee046197ee + github.com/basecamp/actioncable-go v1.0.1-0.20260920013943-0a28da604c70 github.com/basecamp/hey-sdk/go v0.31.1 github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d github.com/charmbracelet/x/ansi v0.11.8 diff --git a/go.sum b/go.sum index 29235bf0..8ca79456 100644 --- a/go.sum +++ b/go.sum @@ -91,6 +91,8 @@ github.com/basecamp/actioncable-go v1.0.0 h1:m8UGFYfBfa/YivQNayc2VoS+oVKLG+ixKzh github.com/basecamp/actioncable-go v1.0.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= github.com/basecamp/actioncable-go v1.0.1-0.20260920012115-d1ee046197ee h1:UokSc0Qnp/2+feQX3gcH5tcw9vRaaPzisstzj2Ml084= github.com/basecamp/actioncable-go v1.0.1-0.20260920012115-d1ee046197ee/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= +github.com/basecamp/actioncable-go v1.0.1-0.20260920013943-0a28da604c70 h1:HuJ7a2jTI7UQLIKhFrvbo/QKR/igzFzsI7Nc7C8acpk= +github.com/basecamp/actioncable-go v1.0.1-0.20260920013943-0a28da604c70/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= github.com/basecamp/hey-sdk/go v0.31.1 h1:dFlCe4LMmLAHgRB05QRHaE26XPPkZVIMp0qCGEanVQU= github.com/basecamp/hey-sdk/go v0.31.1/go.mod h1:eCJOhTLOpi2FCQUXireHZQ7DzRs2TUqvYhRDTfVLoDY= github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4= From f03494f3f58208624b365998a3a589187bbd0dab Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 19 Sep 2026 21:54:10 -0400 Subject: [PATCH 3/8] Refresh dependency metadata for Action Cable --- go.sum | 4 ---- nix/package.nix | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/go.sum b/go.sum index 8ca79456..6f2b9156 100644 --- a/go.sum +++ b/go.sum @@ -87,10 +87,6 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/basecamp/actioncable-go v1.0.0 h1:m8UGFYfBfa/YivQNayc2VoS+oVKLG+ixKzhIzdACWDM= -github.com/basecamp/actioncable-go v1.0.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/actioncable-go v1.0.1-0.20260920012115-d1ee046197ee h1:UokSc0Qnp/2+feQX3gcH5tcw9vRaaPzisstzj2Ml084= -github.com/basecamp/actioncable-go v1.0.1-0.20260920012115-d1ee046197ee/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= github.com/basecamp/actioncable-go v1.0.1-0.20260920013943-0a28da604c70 h1:HuJ7a2jTI7UQLIKhFrvbo/QKR/igzFzsI7Nc7C8acpk= github.com/basecamp/actioncable-go v1.0.1-0.20260920013943-0a28da604c70/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= github.com/basecamp/hey-sdk/go v0.31.1 h1:dFlCe4LMmLAHgRB05QRHaE26XPPkZVIMp0qCGEanVQU= diff --git a/nix/package.nix b/nix/package.nix index 74d66486..8d4462d4 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -18,7 +18,7 @@ buildGoModule.override { inherit go; } (finalAttrs: { # To update: run `make update-nix-hash` (Docker). It rewrites this quoted # value in place, so keep it a string literal rather than lib.fakeHash. - vendorHash = "sha256-O7NBrztIA2L/ERLEF4hwQjWIGZAZkD65V9LvlR/td08="; + vendorHash = "sha256-O2l3j+5F9Z74/0FtAygIJKBaXUVYKpDehU/Q9gy+SdY="; subPackages = [ "cmd/hey" ]; From 5869cd2d3e64acbeb1b9377ba336dcd93887f5a8 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 19 Sep 2026 22:06:54 -0400 Subject: [PATCH 4/8] Surface stopped Cable watches in the TUI --- internal/cmd/tui_watch.go | 47 ++++++++++++++++------- internal/cmd/tui_watch_test.go | 40 +++++++++++++++++++- internal/tui/live.go | 29 ++++++++------ internal/tui/live_test.go | 69 ++++++++++++++++++++++++++++------ internal/tui/tui.go | 23 ++++++++++-- 5 files changed, 167 insertions(+), 41 deletions(-) diff --git a/internal/cmd/tui_watch.go b/internal/cmd/tui_watch.go index 811bbc57..ff1fc12f 100644 --- a/internal/cmd/tui_watch.go +++ b/internal/cmd/tui_watch.go @@ -93,13 +93,13 @@ func watchMailChanges(ctx context.Context) (<-chan tui.MailWatchEvent, error) { events := make(chan tui.MailWatchEvent, mailChangeBacklog) go func() { defer unsubscribe(subscription) - relayMailChanges(ctx, subscription.Messages(), connection, events) + relayMailChanges(ctx, subscription.Messages(), connection, events, subscription.Err) }() return events, nil } -func relayMailChanges(ctx context.Context, messages <-chan actioncable.Message, connection *mailConnectionNotifier, events chan tui.MailWatchEvent) { +func relayMailChanges(ctx context.Context, messages <-chan actioncable.Message, connection *mailConnectionNotifier, events chan tui.MailWatchEvent, stoppedBecause func() error) { defer close(events) var connectionVersion uint64 @@ -116,6 +116,11 @@ func relayMailChanges(ctx context.Context, messages <-chan actioncable.Message, ringMailWatchEvent(events, event) case message, open := <-messages: if !open { + if stoppedBecause != nil { + if err := stoppedBecause(); err != nil { + ringMailWatchEvent(events, tui.MailWatchEvent{Err: watchDialError(err)}) + } + } return } var notification struct { @@ -187,7 +192,7 @@ const calendarListPollInterval = 5 * time.Minute // them all over the TUI's shared connection and folds them into one doorbell. What HEY // broadcasts is markup for the web app: nothing is read out of it, the arrival is the // whole message, and the TUI re-reads the span on screen behind it. -func watchCalendarChanges(ctx, connectionCtx context.Context) (<-chan struct{}, error) { +func watchCalendarChanges(ctx, connectionCtx context.Context) (<-chan tui.CalendarWatchEvent, error) { list, err := sdk.Calendars().ListWithChanges(ctx) if err != nil { return nil, apierr.FromSDK(err) @@ -217,15 +222,15 @@ func watchCalendarChanges(ctx, connectionCtx context.Context) (<-chan struct{}, // dead instead, which tears the whole watch down — the TUI reopens it, resubscribing // everything, rather than limping on with some calendars gone quiet. type calendarStreamWatch struct { - changes chan struct{} - dead chan struct{} + changes chan tui.CalendarWatchEvent + dead chan error stops map[int64]context.CancelFunc } func newCalendarStreamWatch() *calendarStreamWatch { return &calendarStreamWatch{ - changes: make(chan struct{}, 1), - dead: make(chan struct{}, 1), + changes: make(chan tui.CalendarWatchEvent, 1), + dead: make(chan error, 1), stops: map[int64]context.CancelFunc{}, } } @@ -244,7 +249,7 @@ func (w *calendarStreamWatch) subscribe(ctx, connectionCtx context.Context, cale Params: actioncable.Params{"signed_stream_name": calendar.SignedStreamName}, }, actioncable.OnConnected(func(reconnected bool) { if reconnected { - ring(w.changes, struct{}{}) + ring(w.changes, tui.CalendarWatchEvent{}) } })) if err != nil { @@ -262,11 +267,11 @@ func (w *calendarStreamWatch) subscribe(ctx, connectionCtx context.Context, cale case _, open := <-subscription.Messages(): if !open { if subCtx.Err() == nil { - ring(w.dead, struct{}{}) + ring(w.dead, subscription.Err()) } return } - ring(w.changes, struct{}{}) + ring(w.changes, tui.CalendarWatchEvent{}) } } }() @@ -290,7 +295,10 @@ func (w *calendarStreamWatch) run(ctx, connectionCtx context.Context, cursor hey select { case <-ctx.Done(): return - case <-w.dead: + case err := <-w.dead: + if err != nil { + ringCalendarWatchEvent(w.changes, tui.CalendarWatchEvent{Err: watchDialError(err)}) + } return case <-poll.C: next, alive := w.pollOnce(ctx, connectionCtx, cursor) @@ -321,7 +329,7 @@ func (w *calendarStreamWatch) pollOnce(ctx, connectionCtx context.Context, curso w.drop(deleted.ID) } if len(changes.Added)+len(changes.Updated)+len(changes.Deleted) > 0 { - ring(w.changes, struct{}{}) + ring(w.changes, tui.CalendarWatchEvent{}) } if changes.NextCursor != nil { cursor = *changes.NextCursor @@ -357,7 +365,7 @@ func unsubscribe(subscription *actioncable.Subscription) { // transition empties that backlog so the global status changes promptly, and reconnecting // catches the visible box up without relying on an older doorbell. func ringMailWatchEvent(events chan tui.MailWatchEvent, event tui.MailWatchEvent) { - if event.Connection == tui.MailConnectionUnchanged { + if event.Connection == tui.MailConnectionUnchanged && event.Err == nil { select { case events <- event: return @@ -404,6 +412,19 @@ func ringMailWatchEvent(events chan tui.MailWatchEvent, event tui.MailWatchEvent } } +func ringCalendarWatchEvent(events chan tui.CalendarWatchEvent, event tui.CalendarWatchEvent) { + if event.Err == nil { + ring(events, event) + return + } + + select { + case <-events: + default: + } + ring(events, event) +} + // ring drops the notification when one is already waiting: they all say the same thing, // and a reader that has fallen behind must not hold up the goroutine doing the ringing. func ring[T any](notifications chan<- T, notification T) { diff --git a/internal/cmd/tui_watch_test.go b/internal/cmd/tui_watch_test.go index f255c26d..160bb425 100644 --- a/internal/cmd/tui_watch_test.go +++ b/internal/cmd/tui_watch_test.go @@ -25,7 +25,7 @@ func TestRelayMailChangesNamesTheChangedBoxAndConnectionState(t *testing.T) { connection := newMailConnectionNotifier() events := make(chan tui.MailWatchEvent, mailChangeBacklog) - go relayMailChanges(t.Context(), messages, connection, events) + go relayMailChanges(t.Context(), messages, connection, events, nil) messages <- actioncable.Message(`{"change":"upsert","box_id":24088}`) if got := <-events; got.BoxID != 24088 || got.Connection != tui.MailConnectionUnchanged { @@ -49,6 +49,26 @@ func TestRelayMailChangesNamesTheChangedBoxAndConnectionState(t *testing.T) { } } +func TestRelayMailChangesReportsWhyAnEstablishedSubscriptionStopped(t *testing.T) { + messages := make(chan actioncable.Message) + connection := newMailConnectionNotifier() + events := make(chan tui.MailWatchEvent, mailChangeBacklog) + events <- tui.MailWatchEvent{BoxID: 1} + refused := &actioncable.DisconnectError{Reason: actioncable.ReasonUnauthorized, Reconnect: false} + + go relayMailChanges(t.Context(), messages, connection, events, func() error { return refused }) + close(messages) + + var got tui.MailWatchEvent + for event := range events { + got = event + } + var known *apierr.Error + if !errors.As(got.Err, &known) || known.Code != apierr.CodeAuth { + t.Errorf("final event error = %T %v, want an authentication error", got.Err, got.Err) + } +} + func TestMailConnectionNotifierKeepsTheNewestRapidTransition(t *testing.T) { connection := newMailConnectionNotifier() connection.note(tui.MailConnectionDisconnected, true) @@ -92,7 +112,7 @@ func TestARelayIsTheOnlyWriterToTheStreamItCloses(t *testing.T) { mail := make(chan tui.MailWatchEvent, mailChangeBacklog) screener := make(chan struct{}, 1) - go relayMailChanges(relaying, mailMessages, connection, mail) + go relayMailChanges(relaying, mailMessages, connection, mail, nil) go relayScreenerChanges(relaying, screenerMessages, reconnects, screener) stop() @@ -310,6 +330,22 @@ func TestTuiSubscribeReplacesAClientThatStoppedItself(t *testing.T) { } } +func TestCalendarStreamReportsWhyAnEstablishedSubscriptionStopped(t *testing.T) { + watch := newCalendarStreamWatch() + watch.changes <- tui.CalendarWatchEvent{} + go watch.run(t.Context(), t.Context(), hey.CalendarChangesCursor{}) + + watch.dead <- &actioncable.DisconnectError{Reason: actioncable.ReasonUnauthorized, Reconnect: false} + var got tui.CalendarWatchEvent + for event := range watch.changes { + got = event + } + var known *apierr.Error + if !errors.As(got.Err, &known) || known.Code != apierr.CodeAuth { + t.Errorf("final event error = %T %v, want an authentication error", got.Err, got.Err) + } +} + func TestRingDropsWhatWouldBlock(t *testing.T) { notifications := make(chan struct{}, 1) diff --git a/internal/tui/live.go b/internal/tui/live.go index 855d5d10..9d4c3f1a 100644 --- a/internal/tui/live.go +++ b/internal/tui/live.go @@ -33,13 +33,15 @@ const ( MailConnectionReconnected ) -// MailWatchEvent reports either a changed box or a connection transition. A disconnected -// event says whether the connection is already retrying; a reconnect asks the TUI to -// catch up the box on screen because broadcasts sent during the gap were missed. +// MailWatchEvent reports a changed box, a connection transition, or why an established +// watch stopped. A disconnected event says whether the connection is already retrying; +// a reconnect asks the TUI to catch up the box on screen because broadcasts sent during +// the gap were missed. Err is set only for the final event before the stream closes. type MailWatchEvent struct { BoxID int64 Connection MailConnection WillReconnect bool + Err error } // ScreenerWatcher opens the stream that says The Screener changed. ctx owns this signed @@ -48,12 +50,16 @@ type MailWatchEvent struct { // a watcher opens after that name has been read. type ScreenerWatcher func(ctx, connectionCtx context.Context, signedStreamName string) (<-chan struct{}, error) +// CalendarWatchEvent reports a calendar change or why an established watch stopped. Err +// is set only for the final event before the stream closes. +type CalendarWatchEvent struct{ Err error } + // CalendarWatcher opens the stream that says a calendar changed. It subscribes every // calendar the account can see and folds them into one doorbell: which calendar rang does // not matter, because the TUI re-reads whatever span is on screen either way. A watcher // discovers calendars added or removed while it runs on its own and rings for those too. // The stream closes when ctx is done, or when whatever is behind it has given up for good. -type CalendarWatcher func(ctx, connectionCtx context.Context) (<-chan struct{}, error) +type CalendarWatcher func(ctx, connectionCtx context.Context) (<-chan CalendarWatchEvent, error) // AnyBoxChanged stands for "something changed, we don't know what" — a watcher sends it // after a reconnect, where the changes broadcast while it was away were missed. @@ -268,15 +274,16 @@ func refreshScreenerLaterCmd(delay time.Duration) tea.Cmd { // over the current one. type calendarWatchStartedMsg struct { attempt uint64 - changes <-chan struct{} + changes <-chan CalendarWatchEvent err error } -// calendarChangedMsg reports that a calendar changed, or that the stream has closed. The -// frame HEY broadcasts carries nothing the TUI can use: it is a doorbell, and the span on -// screen is read again behind it. +// calendarChangedMsg reports that a calendar changed, why its established watch stopped, +// or that the stream has closed. The ordinary frame HEY broadcasts carries nothing the +// TUI can use: it is a doorbell, and the span on screen is read again behind it. type calendarChangedMsg struct { attempt uint64 + event CalendarWatchEvent closed bool } @@ -298,13 +305,13 @@ func startCalendarWatchCmd(ctx, connectionCtx context.Context, watch CalendarWat } } -func waitForCalendarChangeCmd(attempt uint64, changes <-chan struct{}) tea.Cmd { +func waitForCalendarChangeCmd(attempt uint64, changes <-chan CalendarWatchEvent) tea.Cmd { if changes == nil { return nil } return func() tea.Msg { - _, open := <-changes - return calendarChangedMsg{attempt: attempt, closed: !open} + event, open := <-changes + return calendarChangedMsg{attempt: attempt, event: event, closed: !open} } } diff --git a/internal/tui/live_test.go b/internal/tui/live_test.go index 241e4cdb..b15cd7ff 100644 --- a/internal/tui/live_test.go +++ b/internal/tui/live_test.go @@ -141,6 +141,26 @@ func TestModelShowsAndClearsATemporaryDisconnectAcrossSections(t *testing.T) { } } +func TestModelStopsRetryingWhenAnEstablishedMailWatchReportsAuthenticationFailure(t *testing.T) { + m := newModel() + m.width, m.height = 80, 30 + m.vc.width = 80 + m.mailWatchEvents = make(chan MailWatchEvent) + + refused := apierr.ErrAuth("HEY's cable server turned these credentials down") + updated, cmd := m.Update(mailWatchEventMsg{event: MailWatchEvent{Err: refused}}) + m = updated.(model) + if cmd != nil { + t.Error("an authentication failure should not enter a reconnect loop") + } + if m.mailWatchEvents != nil { + t.Error("the failed stream should be let go of") + } + if notice := m.mailWatchNotice(); !strings.Contains(notice, "credentials") { + t.Errorf("notice = %q, want the authentication failure", notice) + } +} + func TestModelReportsAWatcherThatNeverStarted(t *testing.T) { m := newModel() m.width, m.height = 80, 30 @@ -710,8 +730,8 @@ func mailWithBoxServer(t *testing.T, postingsJSON string) (*mailView, *recordedR // --- The calendar's streams --- func TestWaitForCalendarChangeReportsTheRingAndTheClose(t *testing.T) { - changes := make(chan struct{}, 1) - changes <- struct{}{} + changes := make(chan CalendarWatchEvent, 1) + changes <- CalendarWatchEvent{} if rung := waitForCalendarChangeCmd(3, changes)().(calendarChangedMsg); rung.closed || rung.attempt != 3 { t.Errorf("rung = %+v, want an open stream on attempt 3", rung) @@ -728,15 +748,15 @@ func TestWaitForCalendarChangeReportsTheRingAndTheClose(t *testing.T) { } func TestStartCalendarWatchCarriesTheStreamAttemptOrReason(t *testing.T) { - changes := make(chan struct{}) - opened := startCalendarWatchCmd(context.Background(), context.Background(), func(_, _ context.Context) (<-chan struct{}, error) { + changes := make(chan CalendarWatchEvent) + opened := startCalendarWatchCmd(context.Background(), context.Background(), func(_, _ context.Context) (<-chan CalendarWatchEvent, error) { return changes, nil }, 4)().(calendarWatchStartedMsg) if opened.changes == nil || opened.err != nil || opened.attempt != 4 { t.Errorf("opened = %+v, want attempt 4 and its stream", opened) } - refused := startCalendarWatchCmd(context.Background(), context.Background(), func(_, _ context.Context) (<-chan struct{}, error) { + refused := startCalendarWatchCmd(context.Background(), context.Background(), func(_, _ context.Context) (<-chan CalendarWatchEvent, error) { return nil, errors.New("cable server said no") }, 5)().(calendarWatchStartedMsg) if refused.err == nil || refused.attempt != 5 { @@ -750,7 +770,9 @@ func TestStartCalendarWatchCarriesTheStreamAttemptOrReason(t *testing.T) { func TestModelFollowsTheCalendarOnlyWhileItIsOnScreen(t *testing.T) { m := newModel() - m.watchCalendar = func(_, _ context.Context) (<-chan struct{}, error) { return make(chan struct{}), nil } + m.watchCalendar = func(_, _ context.Context) (<-chan CalendarWatchEvent, error) { + return make(chan CalendarWatchEvent), nil + } updated, cmd := m.switchSection(sectionCalendar) m = updated.(model) @@ -759,7 +781,7 @@ func TestModelFollowsTheCalendarOnlyWhileItIsOnScreen(t *testing.T) { } attempt := m.calendarWatchAttempt - changes := make(chan struct{}, 1) + changes := make(chan CalendarWatchEvent, 1) updated, _ = m.Update(calendarWatchStartedMsg{attempt: attempt, changes: changes}) m = updated.(model) if m.calendarChanges == nil { @@ -781,11 +803,13 @@ func TestModelFollowsTheCalendarOnlyWhileItIsOnScreen(t *testing.T) { func TestModelIgnoresAStaleCalendarStream(t *testing.T) { m := newModel() - m.watchCalendar = func(_, _ context.Context) (<-chan struct{}, error) { return make(chan struct{}), nil } + m.watchCalendar = func(_, _ context.Context) (<-chan CalendarWatchEvent, error) { + return make(chan CalendarWatchEvent), nil + } updated, _ := m.switchSection(sectionCalendar) m = updated.(model) - stale := make(chan struct{}, 1) + stale := make(chan CalendarWatchEvent, 1) updated, cmd := m.Update(calendarWatchStartedMsg{attempt: m.calendarWatchAttempt - 1, changes: stale}) m = updated.(model) if m.calendarChanges != nil || cmd != nil { @@ -798,7 +822,7 @@ func TestModelArmsOneCalendarReReadPerRing(t *testing.T) { m.section = sectionCalendar m.activeView = m.calendarView m.calendarWatchAttempt = 2 - m.calendarChanges = make(chan struct{}) + m.calendarChanges = make(chan CalendarWatchEvent) updated, cmd := m.Update(calendarChangedMsg{attempt: 2}) m = updated.(model) @@ -815,7 +839,9 @@ func TestModelArmsOneCalendarReReadPerRing(t *testing.T) { func TestModelRetriesAClosedCalendarStreamWhileWatching(t *testing.T) { m := newModel() - m.watchCalendar = func(_, _ context.Context) (<-chan struct{}, error) { return make(chan struct{}), nil } + m.watchCalendar = func(_, _ context.Context) (<-chan CalendarWatchEvent, error) { + return make(chan CalendarWatchEvent), nil + } updated, _ := m.switchSection(sectionCalendar) m = updated.(model) attempt := m.calendarWatchAttempt @@ -833,9 +859,28 @@ func TestModelRetriesAClosedCalendarStreamWhileWatching(t *testing.T) { } } +func TestModelDoesNotRetryACalendarWatchAfterAuthenticationFailure(t *testing.T) { + m := newModel() + m.watchCalendar = func(_, _ context.Context) (<-chan CalendarWatchEvent, error) { + return make(chan CalendarWatchEvent), nil + } + updated, _ := m.switchSection(sectionCalendar) + m = updated.(model) + attempt := m.calendarWatchAttempt + + refused := apierr.ErrAuth("HEY's cable server turned these credentials down") + updated, cmd := m.Update(calendarChangedMsg{attempt: attempt, event: CalendarWatchEvent{Err: refused}}) + m = updated.(model) + if cmd != nil || m.stopCalendarWatch != nil || m.calendarWatchFailures != 0 { + t.Errorf("failures = %d, want the failed watch dropped without a retry", m.calendarWatchFailures) + } +} + func TestModelDropsACalendarRetryAfterLeaving(t *testing.T) { m := newModel() - m.watchCalendar = func(_, _ context.Context) (<-chan struct{}, error) { return make(chan struct{}), nil } + m.watchCalendar = func(_, _ context.Context) (<-chan CalendarWatchEvent, error) { + return make(chan CalendarWatchEvent), nil + } updated, _ := m.switchSection(sectionCalendar) m = updated.(model) attempt := m.calendarWatchAttempt diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4cf5bf0a..68f21542 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -100,7 +100,7 @@ type model struct { // screen: every other section re-reads its data on entry, so a doorbell rung for a // section nobody is looking at would be paid for and answered by nothing. watchCalendar CalendarWatcher - calendarChanges <-chan struct{} + calendarChanges <-chan CalendarWatchEvent calendarWatchAttempt uint64 calendarWatchFailures int stopCalendarWatch context.CancelFunc @@ -385,6 +385,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateHelpBindings() return m, cmd } + if msg.event.Err != nil { + cmd := m.mailWatchFailed(msg.event.Err) + m.updateHelpBindings() + return m, cmd + } wait := waitForMailWatchEventCmd(m.mailWatchEvents) switch msg.event.Connection { case MailConnectionUnchanged: @@ -446,7 +451,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if msg.err != nil { - return m, m.retryCalendarWatch() + return m, m.calendarWatchFailed(msg.err) } m.calendarChanges = msg.changes m.calendarWatchFailures = 0 @@ -459,6 +464,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.closed { return m, m.retryCalendarWatch() } + if msg.event.Err != nil { + return m, m.calendarWatchFailed(msg.event.Err) + } return m, tea.Batch(m.calendarChanged(), waitForCalendarChangeCmd(msg.attempt, m.calendarChanges)) case calendarRefreshDueMsg: @@ -1119,14 +1127,23 @@ func (m *model) dropCalendarWatch() { // step the reader takes, so a watch that is down costs staleness, not a broken screen, // and the mail watch already announces a connection that is gone. func (m *model) retryCalendarWatch() tea.Cmd { + failures := m.calendarWatchFailures m.dropCalendarWatch() if !m.watchingCalendars() { return nil } - m.calendarWatchFailures++ + m.calendarWatchFailures = failures + 1 return retryCalendarWatchLaterCmd(m.calendarWatchAttempt, mailWatchRetryDelay(m.calendarWatchFailures)) } +func (m *model) calendarWatchFailed(err error) tea.Cmd { + if retryableMailWatchError(err) { + return m.retryCalendarWatch() + } + m.dropCalendarWatch() + return nil +} + // calendarChanged is the calendar's doorbell. One write lands as several broadcasts — // HEY touches the calendar per recording — so the re-read is delayed and one is armed at // a time. From d6efc5406d9785ff1a66f3e51019de802ab8e047 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 19 Sep 2026 22:18:35 -0400 Subject: [PATCH 5/8] Preserve calendar poll subscription failures --- internal/cmd/tui_watch.go | 17 ++++++----- internal/cmd/tui_watch_test.go | 56 ++++++++++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/internal/cmd/tui_watch.go b/internal/cmd/tui_watch.go index ff1fc12f..7c8f5932 100644 --- a/internal/cmd/tui_watch.go +++ b/internal/cmd/tui_watch.go @@ -301,8 +301,9 @@ func (w *calendarStreamWatch) run(ctx, connectionCtx context.Context, cursor hey } return case <-poll.C: - next, alive := w.pollOnce(ctx, connectionCtx, cursor) - if !alive { + next, err := w.pollOnce(ctx, connectionCtx, cursor) + if err != nil { + ringCalendarWatchEvent(w.changes, tui.CalendarWatchEvent{Err: err}) return } cursor = next @@ -312,17 +313,17 @@ func (w *calendarStreamWatch) run(ctx, connectionCtx context.Context, cursor hey // pollOnce reads the calendar-level feed once. A read that fails is skipped — the cursor // has not moved, so the next poll reads the same changes — but a stream that cannot be -// subscribed ends the watch: the connection is the likely reason, and reopening the whole -// watch resubscribes everything. -func (w *calendarStreamWatch) pollOnce(ctx, connectionCtx context.Context, cursor hey.CalendarChangesCursor) (hey.CalendarChangesCursor, bool) { +// subscribed ends the watch with its reason, so the TUI can tell a temporary connection +// failure from credentials the server refused. +func (w *calendarStreamWatch) pollOnce(ctx, connectionCtx context.Context, cursor hey.CalendarChangesCursor) (hey.CalendarChangesCursor, error) { changes, err := sdk.Calendars().AllCalendarChanges(ctx, cursor) if err != nil { - return cursor, true + return cursor, nil //nolint:nilerr // A failed feed read leaves its cursor for the next poll. } for _, added := range changes.Added { if err := w.subscribe(ctx, connectionCtx, added); err != nil { - return cursor, false + return cursor, err } } for _, deleted := range changes.Deleted { @@ -335,7 +336,7 @@ func (w *calendarStreamWatch) pollOnce(ctx, connectionCtx context.Context, curso cursor = *changes.NextCursor } - return cursor, true + return cursor, nil } func (w *calendarStreamWatch) drop(calendarID int64) { diff --git a/internal/cmd/tui_watch_test.go b/internal/cmd/tui_watch_test.go index 160bb425..d09b76f2 100644 --- a/internal/cmd/tui_watch_test.go +++ b/internal/cmd/tui_watch_test.go @@ -385,9 +385,9 @@ func TestCalendarStreamWatchPollFollowsTheCalendarSet(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - next, alive := watch.pollOnce(context.Background(), context.Background(), cursor) - if !alive { - t.Fatal("a poll that read cleanly should keep the watch alive") + next, err := watch.pollOnce(context.Background(), context.Background(), cursor) + if err != nil { + t.Fatalf("a poll that read cleanly should keep the watch alive: %v", err) } if next.Since != "2026-08-18T09:20:00.000Z" { t.Errorf("cursor = %+v, want it moved to where the feed left off", next) @@ -405,6 +405,50 @@ func TestCalendarStreamWatchPollFollowsTheCalendarSet(t *testing.T) { } } +func TestCalendarStreamWatchPollReportsSubscriptionAuthenticationFailure(t *testing.T) { + t.Setenv("HEY_TOKEN", "") + t.Setenv("HEY_NO_KEYRING", "1") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Link", `<`+r.URL.Path+`?since=2026-08-18T09%3A20%3A00.000Z>; rel="next"`) + _, _ = w.Write([]byte(`{ + "added": [{"calendar": {"id": 514, "name": "Book Club"}, + "recording_changes_url": "/calendars/514/recording/changes.json?since=2026-08-18T09%3A14%3A00.000Z&v=1", + "signed_stream_name": "book-club-stream"}] + }`)) + })) + t.Cleanup(server.Close) + + previousSDK, previousCfg, previousAuthMgr := sdk, cfg, authMgr + sdk = hey.NewClient( + &hey.Config{BaseURL: server.URL}, + &hey.StaticTokenProvider{Token: "test-token"}, + hey.WithMaxRetries(0), + ) + cfg = &config.Config{BaseURL: server.URL} + authMgr = auth.NewManager(server.URL, server.Client(), t.TempDir()) + stopped := actioncable.New("ws://cable.example.test/cable") + _ = stopped.Close() + tuiCable.client = stopped + t.Cleanup(func() { + sdk, cfg, authMgr = previousSDK, previousCfg, previousAuthMgr + tuiCable.client = nil + }) + + watch := newCalendarStreamWatch() + cursor, err := hey.CalendarChangesCursorFrom(server.URL + "/calendar/changes.json?since=2026-08-18T09%3A00%3A00.000Z") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + _, err = watch.pollOnce(context.Background(), context.Background(), cursor) + var known *apierr.Error + if !errors.As(err, &known) || known.Code != apierr.CodeAuth { + t.Errorf("poll error = %T %v, want the subscription authentication failure", err, err) + } +} + func TestCalendarStreamWatchPollSkipsAFailedRead(t *testing.T) { t.Setenv("HEY_TOKEN", "test-token") @@ -420,9 +464,9 @@ func TestCalendarStreamWatchPollSkipsAFailedRead(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - next, alive := watch.pollOnce(context.Background(), context.Background(), cursor) - if !alive { - t.Error("a read that failed should be retried by the next poll, not end the watch") + next, err := watch.pollOnce(context.Background(), context.Background(), cursor) + if err != nil { + t.Errorf("a read that failed should be retried by the next poll, not end the watch: %v", err) } if next.Since != cursor.Since { t.Errorf("cursor = %+v, want it left where it was", next) From 917e968469087b276005b1f34a3c001462d35492 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 19 Sep 2026 22:33:02 -0400 Subject: [PATCH 6/8] Keep terminal calendar errors ahead of doorbells --- internal/cmd/tui_watch.go | 31 +++++++++++++++++++++++-------- internal/cmd/tui_watch_test.go | 18 ++++++++++++++++-- internal/tui/live.go | 10 +++++----- internal/tui/live_test.go | 16 ++++++++++++++++ internal/tui/tui.go | 2 +- 5 files changed, 61 insertions(+), 16 deletions(-) diff --git a/internal/cmd/tui_watch.go b/internal/cmd/tui_watch.go index 7c8f5932..2a0e554b 100644 --- a/internal/cmd/tui_watch.go +++ b/internal/cmd/tui_watch.go @@ -220,9 +220,12 @@ func watchCalendarChanges(ctx, connectionCtx context.Context) (<-chan tui.Calend // calendarStreamWatch is one doorbell over many subscriptions: every calendar's stream // rings the same channel, and a subscription that closes without being given up rings // dead instead, which tears the whole watch down — the TUI reopens it, resubscribing -// everything, rather than limping on with some calendars gone quiet. +// everything, rather than limping on with some calendars gone quiet. Only run writes to +// changes; subscription callbacks ring the internal channel, which is never closed, so a +// late callback cannot race a terminal event or write to closed TUI output. type calendarStreamWatch struct { changes chan tui.CalendarWatchEvent + rings chan struct{} dead chan error stops map[int64]context.CancelFunc } @@ -230,6 +233,7 @@ type calendarStreamWatch struct { func newCalendarStreamWatch() *calendarStreamWatch { return &calendarStreamWatch{ changes: make(chan tui.CalendarWatchEvent, 1), + rings: make(chan struct{}, 1), dead: make(chan error, 1), stops: map[int64]context.CancelFunc{}, } @@ -249,7 +253,7 @@ func (w *calendarStreamWatch) subscribe(ctx, connectionCtx context.Context, cale Params: actioncable.Params{"signed_stream_name": calendar.SignedStreamName}, }, actioncable.OnConnected(func(reconnected bool) { if reconnected { - ring(w.changes, tui.CalendarWatchEvent{}) + ring(w.rings, struct{}{}) } })) if err != nil { @@ -271,7 +275,7 @@ func (w *calendarStreamWatch) subscribe(ctx, connectionCtx context.Context, cale } return } - ring(w.changes, tui.CalendarWatchEvent{}) + ring(w.rings, struct{}{}) } } }() @@ -295,6 +299,8 @@ func (w *calendarStreamWatch) run(ctx, connectionCtx context.Context, cursor hey select { case <-ctx.Done(): return + case <-w.rings: + ring(w.changes, tui.CalendarWatchEvent{}) case err := <-w.dead: if err != nil { ringCalendarWatchEvent(w.changes, tui.CalendarWatchEvent{Err: watchDialError(err)}) @@ -330,7 +336,7 @@ func (w *calendarStreamWatch) pollOnce(ctx, connectionCtx context.Context, curso w.drop(deleted.ID) } if len(changes.Added)+len(changes.Updated)+len(changes.Deleted) > 0 { - ring(w.changes, tui.CalendarWatchEvent{}) + ring(w.rings, struct{}{}) } if changes.NextCursor != nil { cursor = *changes.NextCursor @@ -419,11 +425,20 @@ func ringCalendarWatchEvent(events chan tui.CalendarWatchEvent, event tui.Calend return } - select { - case <-events: - default: + for { + select { + case <-events: + continue + default: + } + select { + case events <- event: + return + default: + // The reader raced the drain and another event took its place. Try the + // current queue again so a terminal error can never be discarded. + } } - ring(events, event) } // ring drops the notification when one is already waiting: they all say the same thing, diff --git a/internal/cmd/tui_watch_test.go b/internal/cmd/tui_watch_test.go index d09b76f2..038f5ea3 100644 --- a/internal/cmd/tui_watch_test.go +++ b/internal/cmd/tui_watch_test.go @@ -332,7 +332,7 @@ func TestTuiSubscribeReplacesAClientThatStoppedItself(t *testing.T) { func TestCalendarStreamReportsWhyAnEstablishedSubscriptionStopped(t *testing.T) { watch := newCalendarStreamWatch() - watch.changes <- tui.CalendarWatchEvent{} + ring(watch.rings, struct{}{}) go watch.run(t.Context(), t.Context(), hey.CalendarChangesCursor{}) watch.dead <- &actioncable.DisconnectError{Reason: actioncable.ReasonUnauthorized, Reconnect: false} @@ -346,6 +346,20 @@ func TestCalendarStreamReportsWhyAnEstablishedSubscriptionStopped(t *testing.T) } } +func TestCalendarStreamLateDoorbellsCannotWriteToItsClosedOutput(t *testing.T) { + ctx, stop := context.WithCancel(t.Context()) + watch := newCalendarStreamWatch() + go watch.run(ctx, ctx, hey.CalendarChangesCursor{}) + stop() + if _, open := <-watch.changes; open { + t.Fatal("the canceled watch should close its output") + } + + for range 3 { + ring(watch.rings, struct{}{}) + } +} + func TestRingDropsWhatWouldBlock(t *testing.T) { notifications := make(chan struct{}, 1) @@ -399,7 +413,7 @@ func TestCalendarStreamWatchPollFollowsTheCalendarSet(t *testing.T) { t.Error("a dropped stream should leave the map") } select { - case <-watch.changes: + case <-watch.rings: default: t.Error("a changed calendar set should ring the doorbell") } diff --git a/internal/tui/live.go b/internal/tui/live.go index 9d4c3f1a..49ef3392 100644 --- a/internal/tui/live.go +++ b/internal/tui/live.go @@ -147,17 +147,17 @@ func mailWatchRetryDelay(failures int) time.Duration { return min(delay, mailWatchMaximumRetry) } -func retryableMailWatchError(err error) bool { +func retryableWatchError(err error) bool { var known *apierr.Error - if errors.As(err, &known) { - return known.Code == apierr.CodeNetwork + if !errors.As(err, &known) { + known = apierr.AsError(apierr.FromSDK(err)) } - return apierr.AsError(apierr.FromSDK(err)).Code == apierr.CodeNetwork + return known.Code == apierr.CodeNetwork || known.Code == apierr.CodeRateLimit } func (m *model) mailWatchFailed(err error) tea.Cmd { m.mailWatchEvents = nil - if retryableMailWatchError(err) { + if retryableWatchError(err) { m.mailWatchStatus = mailWatchReconnecting m.mailWatchReason = "Offline — reconnecting to HEY" return m.retryMailWatch() diff --git a/internal/tui/live_test.go b/internal/tui/live_test.go index b15cd7ff..3b5c3cd7 100644 --- a/internal/tui/live_test.go +++ b/internal/tui/live_test.go @@ -859,6 +859,22 @@ func TestModelRetriesAClosedCalendarStreamWhileWatching(t *testing.T) { } } +func TestModelRetriesACalendarWatchAfterRateLimit(t *testing.T) { + m := newModel() + m.watchCalendar = func(_, _ context.Context) (<-chan CalendarWatchEvent, error) { + return make(chan CalendarWatchEvent), nil + } + updated, _ := m.switchSection(sectionCalendar) + m = updated.(model) + + limited := apierr.ErrRateLimit(30) + updated, cmd := m.Update(calendarWatchStartedMsg{attempt: m.calendarWatchAttempt, err: limited}) + m = updated.(model) + if cmd == nil || m.stopCalendarWatch != nil || m.calendarWatchFailures != 1 { + t.Errorf("failures = %d, want the rate-limited watch dropped and a retry armed", m.calendarWatchFailures) + } +} + func TestModelDoesNotRetryACalendarWatchAfterAuthenticationFailure(t *testing.T) { m := newModel() m.watchCalendar = func(_, _ context.Context) (<-chan CalendarWatchEvent, error) { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 68f21542..93d16782 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1137,7 +1137,7 @@ func (m *model) retryCalendarWatch() tea.Cmd { } func (m *model) calendarWatchFailed(err error) tea.Cmd { - if retryableMailWatchError(err) { + if retryableWatchError(err) { return m.retryCalendarWatch() } m.dropCalendarWatch() From 46710449b5c3e2f189b1dcbb4b50238dd2737fd0 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sat, 19 Sep 2026 22:42:19 -0400 Subject: [PATCH 7/8] Retry temporary calendar watch failures --- internal/tui/live.go | 11 ++++++++++- internal/tui/live_test.go | 16 ++++++++++++++++ internal/tui/tui.go | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/internal/tui/live.go b/internal/tui/live.go index 49ef3392..d2d53ceb 100644 --- a/internal/tui/live.go +++ b/internal/tui/live.go @@ -148,11 +148,20 @@ func mailWatchRetryDelay(failures int) time.Duration { } func retryableWatchError(err error) bool { + code := watchErrorCode(err) + return code == apierr.CodeNetwork || code == apierr.CodeRateLimit +} + +func authenticationWatchError(err error) bool { + return watchErrorCode(err) == apierr.CodeAuth +} + +func watchErrorCode(err error) string { var known *apierr.Error if !errors.As(err, &known) { known = apierr.AsError(apierr.FromSDK(err)) } - return known.Code == apierr.CodeNetwork || known.Code == apierr.CodeRateLimit + return known.Code } func (m *model) mailWatchFailed(err error) tea.Cmd { diff --git a/internal/tui/live_test.go b/internal/tui/live_test.go index 3b5c3cd7..d8dbd00a 100644 --- a/internal/tui/live_test.go +++ b/internal/tui/live_test.go @@ -875,6 +875,22 @@ func TestModelRetriesACalendarWatchAfterRateLimit(t *testing.T) { } } +func TestModelRetriesACalendarWatchAfterCredentialStorageFailure(t *testing.T) { + m := newModel() + m.watchCalendar = func(_, _ context.Context) (<-chan CalendarWatchEvent, error) { + return make(chan CalendarWatchEvent), nil + } + updated, _ := m.switchSection(sectionCalendar) + m = updated.(model) + + unavailable := errors.New("could not read stored credentials: keyring is locked") + updated, cmd := m.Update(calendarWatchStartedMsg{attempt: m.calendarWatchAttempt, err: unavailable}) + m = updated.(model) + if cmd == nil || m.stopCalendarWatch != nil || m.calendarWatchFailures != 1 { + t.Errorf("failures = %d, want the storage failure to schedule a retry", m.calendarWatchFailures) + } +} + func TestModelDoesNotRetryACalendarWatchAfterAuthenticationFailure(t *testing.T) { m := newModel() m.watchCalendar = func(_, _ context.Context) (<-chan CalendarWatchEvent, error) { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 93d16782..0d3dd955 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1137,7 +1137,7 @@ func (m *model) retryCalendarWatch() tea.Cmd { } func (m *model) calendarWatchFailed(err error) tea.Cmd { - if retryableWatchError(err) { + if !authenticationWatchError(err) { return m.retryCalendarWatch() } m.dropCalendarWatch() From e165bef5b374bb676f07b90d639ee4ea303d2833 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sun, 20 Sep 2026 12:41:39 -0400 Subject: [PATCH 8/8] Use Action Cable v1.1.0 --- go.mod | 2 +- go.sum | 4 ++-- nix/package.nix | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index e3280cdd..f1cadb43 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( charm.land/bubbletea/v2 v2.0.9 charm.land/glamour/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/actioncable-go v1.0.1-0.20260920013943-0a28da604c70 + github.com/basecamp/actioncable-go v1.1.0 github.com/basecamp/hey-sdk/go v0.31.1 github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d github.com/charmbracelet/x/ansi v0.11.8 diff --git a/go.sum b/go.sum index 6f2b9156..656af9d5 100644 --- a/go.sum +++ b/go.sum @@ -87,8 +87,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/basecamp/actioncable-go v1.0.1-0.20260920013943-0a28da604c70 h1:HuJ7a2jTI7UQLIKhFrvbo/QKR/igzFzsI7Nc7C8acpk= -github.com/basecamp/actioncable-go v1.0.1-0.20260920013943-0a28da604c70/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= +github.com/basecamp/actioncable-go v1.1.0 h1:AizmCxoKvmUMdqwMQCCLQrFaVbjp8RhkneWidxygc7E= +github.com/basecamp/actioncable-go v1.1.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= github.com/basecamp/hey-sdk/go v0.31.1 h1:dFlCe4LMmLAHgRB05QRHaE26XPPkZVIMp0qCGEanVQU= github.com/basecamp/hey-sdk/go v0.31.1/go.mod h1:eCJOhTLOpi2FCQUXireHZQ7DzRs2TUqvYhRDTfVLoDY= github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4= diff --git a/nix/package.nix b/nix/package.nix index 8d4462d4..1fd2d6e6 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -18,7 +18,7 @@ buildGoModule.override { inherit go; } (finalAttrs: { # To update: run `make update-nix-hash` (Docker). It rewrites this quoted # value in place, so keep it a string literal rather than lib.fakeHash. - vendorHash = "sha256-O2l3j+5F9Z74/0FtAygIJKBaXUVYKpDehU/Q9gy+SdY="; + vendorHash = "sha256-ZfLot6LMYS4yL+5A4Jb9qzNVRobgUuVqXkuWfnX751k="; subPackages = [ "cmd/hey" ];