fix(client): redial over ssh at the offline link state
On a LinkStateOffline event, draw the toast and return a retryable errLinkOffline so Run re-dials with the resume token at ~30s of silence instead of waiting for the transport to reach Dead at 60s. Degraded and Probing stay toast-only.
- commit
- 5726ecedecd68a3fe36819d10d25611c783a54d9
- author
- brice · · 2026-07-08 16:52 UTC
- parent
- 4bab8d8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +37 -0
M internal/usecase/client/client.go +6 -0
| @@ -458,6 +458,12 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 458 | 458 | continue | |
| 459 | 459 | } | |
| 460 | 460 | drawStatusStage(stageForLinkState(ev.State)) | |
| 461 | + | if ev.State == ports.LinkStateOffline { | |
| 462 | + | // Stop waiting for the transport to reach Dead at 60s: exit with | |
| 463 | + | // a retryable error so Run re-dials over ssh with the resume | |
| 464 | + | // token. Run closes this transport on the way out. | |
| 465 | + | return welcomedResult(errLinkOffline) | |
| 466 | + | } | |
| 461 | 467 | case <-colorQueryCh: | |
| 462 | 468 | if err := term.QueryColors(); err != nil { | |
| 463 | 469 | log.Warn("querying terminal colors", "err", err) | |
| @@ -458,6 +458,12 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 458 | continue | 458 | continue | ||
| 459 | } | 459 | } | ||
| 460 | drawStatusStage(stageForLinkState(ev.State)) | 460 | drawStatusStage(stageForLinkState(ev.State)) | ||
| 461 | + | if ev.State == ports.LinkStateOffline { | |||
| 462 | + | // Stop waiting for the transport to reach Dead at 60s: exit with | |||
| 463 | + | // a retryable error so Run re-dials over ssh with the resume | |||
| 464 | + | // token. Run closes this transport on the way out. | |||
| 465 | + | return welcomedResult(errLinkOffline) | |||
| 466 | + | } | |||
| 461 | case <-colorQueryCh: | 467 | case <-colorQueryCh: | ||
| 462 | if err := term.QueryColors(); err != nil { | 468 | if err := term.QueryColors(); err != nil { | ||
| 463 | log.Warn("querying terminal colors", "err", err) | 469 | log.Warn("querying terminal colors", "err", err) | ||
M internal/usecase/client/reconnect.go +5 -0
| @@ -14,6 +14,11 @@ type reconnectBackoff struct { | |||
| 14 | 14 | ||
| 15 | 15 | var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second} | |
| 16 | 16 | ||
| 17 | + | // errLinkOffline is returned by attachOnce when the transport link reaches the | |
| 18 | + | // Offline state. It is retryable so Run re-dials over ssh with the resume token | |
| 19 | + | // at ~30s of silence instead of waiting the full Dead timeout at 60s. | |
| 20 | + | var errLinkOffline = errors.New("vev: link offline; reconnecting") | |
| 21 | + | ||
| 17 | 22 | type reconnectStage int | |
| 18 | 23 | ||
| 19 | 24 | const ( | |
| @@ -14,6 +14,11 @@ type reconnectBackoff struct { | |||||
| 14 | 14 | ||||
| 15 | var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second} | 15 | var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second} | ||
| 16 | 16 | ||||
| 17 | + | // errLinkOffline is returned by attachOnce when the transport link reaches the | |||
| 18 | + | // Offline state. It is retryable so Run re-dials over ssh with the resume token | |||
| 19 | + | // at ~30s of silence instead of waiting the full Dead timeout at 60s. | |||
| 20 | + | var errLinkOffline = errors.New("vev: link offline; reconnecting") | |||
| 21 | + | ||||
| 17 | type reconnectStage int | 22 | type reconnectStage int | ||
| 18 | 23 | ||||
| 19 | const ( | 24 | const ( | ||
M internal/usecase/client/reconnect_toast_test.go +26 -0
| @@ -187,6 +187,32 @@ func TestAttachOnceLinkStateEventsDriveReconnectUIStage(t *testing.T) { | |||
| 187 | 187 | require.True(t, result.welcomed) | |
| 188 | 188 | } | |
| 189 | 189 | ||
| 190 | + | func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) { | |
| 191 | + | term := newReconnectToastTerminalHarness(t) | |
| 192 | + | defer term.closeInput() | |
| 193 | + | tr := newReconnectToastLinkTransport() | |
| 194 | + | tr.recvCh <- reconnectToastRecv{frame: reconnectToastWelcome(44)} | |
| 195 | + | ||
| 196 | + | stages := make(chan reconnectStage, 4) | |
| 197 | + | resultCh := make(chan attachResult, 1) | |
| 198 | + | ms := milestones{} | |
| 199 | + | go func() { | |
| 200 | + | resultCh <- attachOnce(context.Background(), tr, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { | |
| 201 | + | _, err := term.term.EnterRaw() | |
| 202 | + | return err | |
| 203 | + | }, func() {}, func(stage reconnectStage) { | |
| 204 | + | stages <- stage | |
| 205 | + | }, tr.LinkEvents(), true, nil, slog.New(slog.DiscardHandler)) | |
| 206 | + | }() | |
| 207 | + | ||
| 208 | + | tr.events <- ports.LinkEvent{State: ports.LinkStateOffline} | |
| 209 | + | require.Equal(t, reconnectStageOfflineRetrying, <-stages) | |
| 210 | + | result := <-resultCh | |
| 211 | + | require.ErrorIs(t, result.err, errLinkOffline) | |
| 212 | + | require.True(t, result.welcomed) | |
| 213 | + | require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable") | |
| 214 | + | } | |
| 215 | + | ||
| 190 | 216 | func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | |
| 191 | 217 | var out bytes.Buffer | |
| 192 | 218 | size := domain.Size{Cols: 80, Rows: 24} | |
| @@ -187,6 +187,32 @@ func TestAttachOnceLinkStateEventsDriveReconnectUIStage(t *testing.T) { | |||||
| 187 | require.True(t, result.welcomed) | 187 | require.True(t, result.welcomed) | ||
| 188 | } | 188 | } | ||
| 189 | 189 | ||||
| 190 | + | func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) { | |||
| 191 | + | term := newReconnectToastTerminalHarness(t) | |||
| 192 | + | defer term.closeInput() | |||
| 193 | + | tr := newReconnectToastLinkTransport() | |||
| 194 | + | tr.recvCh <- reconnectToastRecv{frame: reconnectToastWelcome(44)} | |||
| 195 | + | ||||
| 196 | + | stages := make(chan reconnectStage, 4) | |||
| 197 | + | resultCh := make(chan attachResult, 1) | |||
| 198 | + | ms := milestones{} | |||
| 199 | + | go func() { | |||
| 200 | + | resultCh <- attachOnce(context.Background(), tr, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, func() error { | |||
| 201 | + | _, err := term.term.EnterRaw() | |||
| 202 | + | return err | |||
| 203 | + | }, func() {}, func(stage reconnectStage) { | |||
| 204 | + | stages <- stage | |||
| 205 | + | }, tr.LinkEvents(), true, nil, slog.New(slog.DiscardHandler)) | |||
| 206 | + | }() | |||
| 207 | + | ||||
| 208 | + | tr.events <- ports.LinkEvent{State: ports.LinkStateOffline} | |||
| 209 | + | require.Equal(t, reconnectStageOfflineRetrying, <-stages) | |||
| 210 | + | result := <-resultCh | |||
| 211 | + | require.ErrorIs(t, result.err, errLinkOffline) | |||
| 212 | + | require.True(t, result.welcomed) | |||
| 213 | + | require.True(t, shouldReconnect(result.err), "offline exit must be reconnectable") | |||
| 214 | + | } | |||
| 215 | + | ||||
| 190 | func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | 216 | func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | ||
| 191 | var out bytes.Buffer | 217 | var out bytes.Buffer | ||
| 192 | size := domain.Size{Cols: 80, Rows: 24} | 218 | size := domain.Size{Cols: 80, Rows: 24} | ||