Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 34 additions & 21 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -578,25 +578,31 @@ click-to-focus and the replace-not-stack id all live in the plugin), and nothing
desktop-shaped lives in `watch*.go`.

The TUI's mail list follows the same channel and wants less from it. `internal/cmd/tui_watch.go`
subscribes and relays the changed box IDs down a channel; `internal/tui/live.go` defines
that contract as `tui.MailWatcher`, so the TUI never sees cable or auth, and a test hands
it a plain channel. There is no sync cursor: the doorbell says which box changed and
`mailView.refreshBox` reads that box's top page again, which is where a change shows up.
A reconnect sends `tui.AnyBoxChanged`, standing for the changes broadcast while the
connection was down.

`internal/tui/live.go` is also where the two delays live. `liveRefreshDelay` collects one
delivery's changes into a single re-read — a thread rings the doorbell once per posting.
`liveRetryDelay` is how long a re-read waits when a form or a picker is open over the list,
or a write hasn't landed: the change is held rather than dropped, because a re-read
replaces what the reader is looking at. `contentList.refreshHead` is what makes that
safe — unlike `setPostings` it keeps the cursor on its posting and keeps a selection —
and the re-read has its own request lane (`liveRequestID`, `postingsRefreshedMsg`) so it
can never be confused with a read the user asked for, or show the spinner.
subscribes and relays typed `tui.MailWatchEvent` values; `internal/tui/live.go` defines
that `tui.MailWatcher` contract, so the TUI never sees cable or auth, and a test hands it
a plain channel. Box events are doorbells: `mailView.refreshBox` reads that box's top page
again, which is where a change shows up. Connection events carry disconnect/reconnect
state. A reconnect asks for `tui.AnyBoxChanged`, standing for broadcasts sent during the
gap, and the model re-reads the box on screen.

`internal/tui/live.go` owns both refresh and reconnect timing. `liveRefreshDelay` collects
one delivery's changes into a single re-read — a thread rings the doorbell once per
posting. `liveRetryDelay` is how long a re-read waits when a form or a picker is open over
the list, or a write hasn't landed: the change is held rather than dropped, because a
re-read replaces what the reader is looking at. `mailWatchRetryDelay` backs a watch that
could not start or stopped for good from two seconds up to thirty; authentication failures
stand without retrying. `contentList.refreshHead` is what makes a re-read safe — unlike
`setPostings` it keeps the cursor on its posting and keeps a selection — and the re-read
has its own request lane (`liveRequestID`, `postingsRefreshedMsg`) so it can never be
confused with a read the user asked for, or show the spinner.

The watch outlives the view context, which a mail account switch throws away; that is what
`model.watchCtx` is for. When the stream closes for good the mail list says so and stays
saying so until ctrl+r reads the box again.
`model.watchCtx` is for. The model holds connection state above the active section, so an
offline or reconnecting notice remains visible in Mail, Contacts, Calendar and Journal.
A temporary drop is followed by Action Cable's own reconnect; an initial network failure
or a stream that closes for good gets a fresh bounded dial from the model. The initial dial
itself is capped so it can report state instead of waiting inside Action Cable forever.
A successful connection removes the status row and catches the current mail box up.

The Screener is told over a different stream, because haystack has no channel for it:
`Clearance::Broadcasting` re-renders the Screener's own button over a Turbo stream, and
Expand All @@ -614,7 +620,10 @@ ctrl+r, closing The Screener, or the doorbell itself will all reopen a watch the
hung up on. Opening one gives up the one before it — the subscription belongs to the
watch's context, and cancelling is what unsubscribes it, so a stream nobody is following
does not go on ringing. A mail account switch gives it up for the same reason: the signed
name is the account's, and the new account's sources read serves its own.
name is the account's, and the new account's sources read serves its own. The subscription
uses that short-lived context, while `tuiCableClient` is owned by the TUI-wide watch
context; replacing a Screener name therefore unsubscribes it without closing Mail's shared
connection.

The doorbell always re-reads the count, wherever the user is, because the mail list is
where The Screener announces itself. When The Screener is what's on screen the queue is
Expand All @@ -623,9 +632,13 @@ mail list — and held while a decision is in flight or the clear-everything que
On the history tab nothing is read; the pending pane is just marked unloaded, which is
what `switchTab` already looks at. Both watches share one websocket
(`tuiCableClient` in `internal/cmd/tui_watch.go`): two subscriptions, one authorization.
A client that stopped itself — the server said don't come back — answers every `Subscribe`
with `ErrClosed` and never dials again, so `tuiSubscribe` drops it and dials a fresh one
rather than handing a reopened watch a dead connection.
A client that stopped itself preserves its terminal failure and never dials again.
`tuiSubscribe` detects that state after any failed subscription, drops the stopped client,
and dials a fresh one; a replacement that also stops is dropped and its failure is
classified as authentication or network so the TUI can respond consistently. Mail
doorbells are coalesced when their bounded queue
is full, while a connection transition drains those stale doorbells and takes priority;
the reconnect catch-up reads the current state once.

### API documentation

Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,10 @@ Navigate between Mail, Contacts, Calendar, and Journal. The context-sensitive sh
The mail list follows the server. HEY tells the TUI when a box changed over the same
Action Cable connection `hey watch` uses, and the box on screen is read again a moment
later, keeping your place in the list and anything you had selected. A change that arrives
while a form or a picker is open waits for it to close. Press Ctrl+R to read the box again
yourself; if the connection goes away for good, the list says so and Ctrl+R is how you
catch up.
while a form or a picker is open waits for it to close. A standing status below the header
appears in every section while the network is offline or live updates are reconnecting.
The TUI retries the connection, clears the status when it returns, and catches up the box
on screen; Ctrl+R remains available whenever you want to read it yourself.

The Screener keeps up too. When a first-time sender writes, the count above the threads
changes on its own, and if you have The Screener open the new sender appears in the queue
Expand Down
4 changes: 4 additions & 0 deletions internal/cable/cable.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ func Dial(ctx context.Context, baseURL string, authMgr *auth.Manager, options ..

client := actioncable.New(cableURL, settings...)
if err := client.Connect(ctx); err != nil {
// Connect's context bounds the caller's wait rather than the client's lifetime.
// A dial that did not complete has no owner to close it, so stop its retry loop
// before returning the error.
_ = client.Close()
return nil, err
}

Expand Down
8 changes: 8 additions & 0 deletions internal/cable/cable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ func TestEveryDialCarriesCurrentCredentials(t *testing.T) {
if got := headers[1+redialed].Get("Origin"); got != "https://app.hey.com" {
t.Errorf("redial Origin = %q, want the client's own headers kept", got)
}

// Connect's deadline only bounds its wait; Dial owns and stops a client that never
// connected so no retry goroutine is left behind after the error.
dialsAtReturn := len(headers)
time.Sleep(10 * time.Millisecond)
if got := len(recorded.recorded()); got != dialsAtReturn {
t.Errorf("dials after return = %d, want the %d attempts already made", got, dialsAtReturn)
}
}

func TestDialWithoutCredentialsFailsBeforeConnecting(t *testing.T) {
Expand Down
193 changes: 151 additions & 42 deletions internal/cmd/tui_watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,53 @@ const turboStreamsChannel = "Turbo::StreamsChannel"
// instead of blocking the relay. A full backlog means a re-read is already coming.
const mailChangeBacklog = 16

// reconnectBacklog is one, because a reconnect always says the same thing. It is a
// channel of its own so that the relay goroutine is the only writer to the channel it
// closes: the cable client drains callbacks queued before it was told to stop, and a
// reconnectBacklog is one, because a Screener reconnect always says the same thing. It
// is a channel of its own so that the relay goroutine is the only writer to the channel
// it closes: the cable client drains callbacks queued before it was told to stop, and a
// send on a closed channel panics whatever the select around it says — off a goroutine
// Bubble Tea knows nothing about, which takes the terminal down in raw mode. Nothing
// closes this one, so a callback arriving after the relay is gone rings into the buffer
// and is collected with it.
const reconnectBacklog = 1

// unsubscribeTimeout bounds the goodbye sent for a watch that is over. Nothing waits on
// it, and a connection that has gone away is reason to stop trying rather than to hang.
const unsubscribeTimeout = 5 * time.Second
type mailConnectionNotifier struct {
sync.Mutex
event tui.MailWatchEvent
version uint64
wake chan struct{}
}

func newMailConnectionNotifier() *mailConnectionNotifier {
return &mailConnectionNotifier{wake: make(chan struct{}, 1)}
}

// note keeps the newest connection state and wakes the relay without blocking Action
// Cable's callback dispatcher. If a disconnect and reconnect arrive before the relay can
// draw either, the reconnect is the state that matters and still asks for a catch-up.
func (n *mailConnectionNotifier) note(connection tui.MailConnection, willReconnect bool) {
n.Lock()
n.event = tui.MailWatchEvent{Connection: connection, WillReconnect: willReconnect}
n.version++
n.Unlock()
ring(n.wake, struct{}{})
}

func (n *mailConnectionNotifier) after(version uint64) (tui.MailWatchEvent, uint64, bool) {
n.Lock()
defer n.Unlock()
return n.event, n.version, n.version != version
}

const (
// unsubscribeTimeout bounds the goodbye sent for a watch that is over. Nothing waits
// on it, and a connection that has gone away is reason to stop trying rather than hang.
unsubscribeTimeout = 5 * time.Second

// tuiCableDialTimeout turns an unreachable cable server into app state instead of
// leaving the startup command inside Action Cable's retry loop forever. The model
// owns the retries after this first bounded attempt.
tuiCableDialTimeout = 5 * time.Second
)

// tuiWatchers are the streams `hey tui` follows to stay live.
func tuiWatchers() tui.Watchers {
Expand All @@ -43,40 +78,45 @@ func tuiWatchers() tui.Watchers {
//
// The stream closes when ctx is done, or when the connection is gone for good, which is
// how the TUI hears that its list has stopped being live.
func watchMailChanges(ctx context.Context) (<-chan int64, error) {
// A reconnect stands for every box: the changes broadcast while the connection was
// down were missed, and the box on screen has to be read again to find them. It
// arrives on a channel of its own so that the relay is the only thing writing to
// the one the TUI reads — see reconnectBacklog.
reconnects := make(chan struct{}, reconnectBacklog)
subscription, err := tuiSubscribe(ctx, actioncable.Identifier{Channel: changesChannel},
func watchMailChanges(ctx context.Context) (<-chan tui.MailWatchEvent, error) {
connection := newMailConnectionNotifier()
subscription, err := tuiSubscribe(ctx, ctx, actioncable.Identifier{Channel: changesChannel},
actioncable.OnConnected(func(reconnected bool) {
if reconnected {
ring(reconnects, struct{}{})
connection.note(tui.MailConnectionReconnected, false)
}
}),
actioncable.OnDisconnected(func(willReconnect bool) {
connection.note(tui.MailConnectionDisconnected, willReconnect)
}))
if err != nil {
return nil, err
}

changes := make(chan int64, mailChangeBacklog)
events := make(chan tui.MailWatchEvent, mailChangeBacklog)
go func() {
defer unsubscribe(ctx, subscription)
relayMailChanges(ctx, subscription.Messages(), reconnects, changes)
relayMailChanges(ctx, subscription.Messages(), connection, events)
}()

return changes, nil
return events, nil
}

func relayMailChanges(ctx context.Context, messages <-chan actioncable.Message, reconnects <-chan struct{}, changes chan<- int64) {
defer close(changes)
func relayMailChanges(ctx context.Context, messages <-chan actioncable.Message, connection *mailConnectionNotifier, events chan tui.MailWatchEvent) {
defer close(events)
var connectionVersion uint64

for {
select {
case <-ctx.Done():
return
case <-reconnects:
ring(changes, tui.AnyBoxChanged)
case <-connection.wake:
event, version, changed := connection.after(connectionVersion)
if !changed {
continue
}
connectionVersion = version
ringMailWatchEvent(events, event)
case message, open := <-messages:
if !open {
return
Expand All @@ -87,11 +127,7 @@ func relayMailChanges(ctx context.Context, messages <-chan actioncable.Message,
if err := message.Unmarshal(&notification); err != nil {
continue
}
select {
case changes <- notification.BoxID:
case <-ctx.Done():
return
}
ringMailWatchEvent(events, tui.MailWatchEvent{BoxID: notification.BoxID})
}
}
}
Expand All @@ -101,9 +137,9 @@ func relayMailChanges(ctx context.Context, messages <-chan actioncable.Message,
// and serves the signed name of that stream with the pending count. What it broadcasts is
// markup for the web app, so nothing is read out of it: the arrival is the whole message,
// and the TUI reads the count again behind it.
func watchScreenerChanges(ctx context.Context, signedStreamName string) (<-chan struct{}, error) {
func watchScreenerChanges(ctx, connectionCtx context.Context, signedStreamName string) (<-chan struct{}, error) {
reconnects := make(chan struct{}, reconnectBacklog)
subscription, err := tuiSubscribe(ctx, actioncable.Identifier{
subscription, err := tuiSubscribe(ctx, connectionCtx, actioncable.Identifier{
Channel: turboStreamsChannel,
Params: actioncable.Params{"signed_stream_name": signedStreamName},
}, actioncable.OnConnected(func(reconnected bool) {
Expand Down Expand Up @@ -153,6 +189,58 @@ func unsubscribe(ctx context.Context, subscription *actioncable.Subscription) {
_ = subscription.Unsubscribe(goodbye)
}

// ringMailWatchEvent keeps connection state ahead of stale box doorbells. Box events can
// be coalesced because one re-read catches up every posting in that box; a connection
// 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 {
select {
case events <- event:
return
default:
Comment thread
robzolkos marked this conversation as resolved.
}

// A full queue may contain changes for boxes other than the one on screen.
// Replace its box-specific doorbells with one catch-all rather than dropping
// this box and assuming another event will happen to refresh it. Keep a queued
// connection transition ahead of that catch-up.
var connection *tui.MailWatchEvent
boxBacklog:
for {
select {
case queued := <-events:
if queued.Connection != tui.MailConnectionUnchanged {
latest := queued
connection = &latest
}
default:
break boxBacklog
}
}
if connection != nil {
ring(events, *connection)
}
ring(events, tui.MailWatchEvent{BoxID: tui.AnyBoxChanged})
return
}

for {
select {
case <-events:
continue
default:
}
select {
case events <- event:
return
default:
// The reader raced us between draining and sending. Try the now-current
// queue again rather than blocking Action Cable's relay.
}
}
}

// 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) {
Expand All @@ -163,27 +251,46 @@ func ring[T any](notifications chan<- T, notification T) {
}

// tuiSubscribe subscribes over the connection the TUI's watches share, dialling a new one
// when the one on hand has stopped itself. A client the server hung up on for good answers
// every Subscribe with ErrClosed and never dials again on its own, so a Screener stream
// reopened after that would find a dead connection and stay dead. Each dial carries
// current credentials, which is what makes redialling worth doing.
func tuiSubscribe(ctx context.Context, identifier actioncable.Identifier, options ...actioncable.SubscriptionOption) (*actioncable.Subscription, error) {
client, err := tuiCableClient(ctx)
// when the one on hand has stopped itself. A stopped client preserves its terminal failure
// and never dials again on its own, so a reopened stream replaces it with a connection that
// carries current credentials.
func tuiSubscribe(ctx, connectionCtx context.Context, identifier actioncable.Identifier, options ...actioncable.SubscriptionOption) (*actioncable.Subscription, error) {
client, err := tuiCableClient(connectionCtx)
if err != nil {
return nil, err
}

subscription, err := client.Subscribe(ctx, identifier, options...)
if errors.Is(err, actioncable.ErrClosed) {
forgetTuiCable(client)
if client, err = tuiCableClient(ctx); err != nil {
return nil, err
}
subscription, err = client.Subscribe(ctx, identifier, options...)
subscription, stopped, err := subscribeTuiCable(ctx, client, identifier, options...)
if !stopped {
return subscription, err
}

client, err = tuiCableClient(connectionCtx)
if err != nil {
return nil, err
}
subscription, _, err = subscribeTuiCable(ctx, client, identifier, options...)
return subscription, err
}

// subscribeTuiCable returns stopped when the shared client needs replacing. Every shared
// client has connected before it is cached, so Connect reports ErrAlreadyConnected while
// it is live or reconnecting and preserves the terminal failure after it stops.
func subscribeTuiCable(ctx context.Context, client *actioncable.Client, identifier actioncable.Identifier, options ...actioncable.SubscriptionOption) (*actioncable.Subscription, bool, error) {
subscription, err := client.Subscribe(ctx, identifier, options...)
if err == nil {
return subscription, false, nil
}

stoppedBecause := client.Connect(ctx)
if errors.Is(stoppedBecause, actioncable.ErrAlreadyConnected) {
return nil, false, err
}

forgetTuiCable(client)
return nil, true, watchDialError(stoppedBecause)
}

// tuiCable is the one connection the TUI's watches share — two subscriptions over one
// websocket, authorized once. It is opened by whichever watch needs it first and closed
// when that watch's context is done, which is the TUI's own lifetime.
Expand All @@ -200,7 +307,9 @@ func tuiCableClient(ctx context.Context) (*actioncable.Client, error) {
return tuiCable.client, nil
}

client, err := cable.Dial(ctx, cfg.BaseURL, authMgr)
dialing, stopDialing := context.WithTimeout(ctx, tuiCableDialTimeout)
defer stopDialing()
client, err := cable.Dial(dialing, cfg.BaseURL, authMgr)
if err != nil {
return nil, watchDialError(err)
}
Expand Down
Loading
Loading