detached 5726ece

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
458458 continue
459459 }
460460 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+ }
461467 case <-colorQueryCh:
462468 if err := term.QueryColors(); err != nil {
463469 log.Warn("querying terminal colors", "err", err)
@@ -458,6 +458,12 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
458 continue458 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 {
1414
1515 var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second}
1616
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+
1722 type reconnectStage int
1823
1924 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 int22 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) {
187187 require.True(t, result.welcomed)
188188 }
189189
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+
190216 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
191217 var out bytes.Buffer
192218 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.Buffer217 var out bytes.Buffer
192 size := domain.Size{Cols: 80, Rows: 24}218 size := domain.Size{Cols: 80, Rows: 24}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close