detached d19dd60

feat(cli): expose durable session recovery state

commit
d19dd603fc36a25442e7200630b4717b75be0261
author
brice · · 2026-07-25 23:39 UTC
parent
1b02243
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +205 -41
M internal/app/run.go +17 -4
@@ -1236,7 +1236,11 @@ func runList(ctx context.Context) error {
12361236 }
12371237 infos := make([]ports.SessionInfo, 0, len(records))
12381238 for _, r := range records {
1239- infos = append(infos, ports.SessionInfo{Name: r.Name, Stopped: true})
1239+ state := ports.SessionStopped
1240+ if r.RecoveryState == domain.RecoveryDegraded || r.RecoveryState == domain.RecoveryDeleting {
1241+ state = ports.SessionDegraded
1242+ }
1243+ infos = append(infos, ports.SessionInfo{Name: r.Name, State: state})
12401244 }
12411245 printSessions(os.Stdout, infos)
12421246 return nil
@@ -1277,11 +1281,20 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
12771281 state := "running"
12781282 tabs := fmt.Sprintf("%d", s.Tabs)
12791283 attached := "no"
1280- if s.Stopped {
1284+ switch s.State {
1285+ case ports.SessionStopped:
12811286 state = "stopped"
12821287 tabs = "-"
1283- } else if s.Ephemeral {
1284- state = "temporary"
1288+ case ports.SessionRestoring:
1289+ state = "restoring"
1290+ tabs = "-"
1291+ case ports.SessionDegraded:
1292+ state = "degraded"
1293+ tabs = "-"
1294+ default:
1295+ if s.Ephemeral {
1296+ state = "temporary"
1297+ }
12851298 }
12861299 if s.Attached {
12871300 attached = "yes"
@@ -1236,7 +1236,11 @@ func runList(ctx context.Context) error {
1236 }1236 }
1237 infos := make([]ports.SessionInfo, 0, len(records))1237 infos := make([]ports.SessionInfo, 0, len(records))
1238 for _, r := range records {1238 for _, r := range records {
1239- infos = append(infos, ports.SessionInfo{Name: r.Name, Stopped: true})1239+ state := ports.SessionStopped
1240+ if r.RecoveryState == domain.RecoveryDegraded || r.RecoveryState == domain.RecoveryDeleting {
1241+ state = ports.SessionDegraded
1242+ }
1243+ infos = append(infos, ports.SessionInfo{Name: r.Name, State: state})
1240 }1244 }
1241 printSessions(os.Stdout, infos)1245 printSessions(os.Stdout, infos)
1242 return nil1246 return nil
@@ -1277,11 +1281,20 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
1277 state := "running"1281 state := "running"
1278 tabs := fmt.Sprintf("%d", s.Tabs)1282 tabs := fmt.Sprintf("%d", s.Tabs)
1279 attached := "no"1283 attached := "no"
1280- if s.Stopped {1284+ switch s.State {
1285+ case ports.SessionStopped:
1281 state = "stopped"1286 state = "stopped"
1282 tabs = "-"1287 tabs = "-"
1283- } else if s.Ephemeral {1288+ case ports.SessionRestoring:
1284- state = "temporary"1289+ state = "restoring"
1290+ tabs = "-"
1291+ case ports.SessionDegraded:
1292+ state = "degraded"
1293+ tabs = "-"
1294+ default:
1295+ if s.Ephemeral {
1296+ state = "temporary"
1297+ }
1285 }1298 }
1286 if s.Attached {1299 if s.Attached {
1287 attached = "yes"1300 attached = "yes"
M internal/app/run_test.go +20 -5
@@ -426,11 +426,26 @@ func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
426426 }
427427 }
428428
429+func TestListShowsDegraded(t *testing.T) {
430+ var out bytes.Buffer
431+ printSessions(&out, []ports.SessionInfo{
432+ {Name: "fresh", State: ports.SessionStopped},
433+ {Name: "loading", State: ports.SessionRestoring},
434+ {Name: "broken", State: ports.SessionDegraded},
435+ })
436+ require.Contains(t, out.String(), "fresh")
437+ require.Contains(t, out.String(), "stopped")
438+ require.Contains(t, out.String(), "loading")
439+ require.Contains(t, out.String(), "restoring")
440+ require.Contains(t, out.String(), "broken")
441+ require.Contains(t, out.String(), "degraded")
442+}
443+
429444 func TestPrintSessionsShowsStoppedState(t *testing.T) {
430445 var out bytes.Buffer
431446 printSessions(&out, []ports.SessionInfo{
432- {Name: "main", Tabs: 2, Attached: true},
433- {Name: "old", Stopped: true},
447+ {Name: "main", State: ports.SessionRunning, Tabs: 2, Attached: true},
448+ {Name: "old", State: ports.SessionStopped},
434449 })
435450 got := out.String()
436451 for _, want := range []string{"NAME", "STATE", "main", "running", "2", "yes", "old", "stopped", "-"} {
@@ -443,9 +458,9 @@ func TestPrintSessionsShowsStoppedState(t *testing.T) {
443458 func TestPrintSessionsMarksEphemeral(t *testing.T) {
444459 var buf bytes.Buffer
445460 printSessions(&buf, []ports.SessionInfo{
446- {Name: "0", Ephemeral: true, Tabs: 1, Attached: false},
447- {Name: "work", Tabs: 2, Attached: true},
448- {Name: "old", Stopped: true},
461+ {Name: "0", State: ports.SessionRunning, Ephemeral: true, Tabs: 1, Attached: false},
462+ {Name: "work", State: ports.SessionRunning, Tabs: 2, Attached: true},
463+ {Name: "old", State: ports.SessionStopped},
449464 })
450465 out := buf.String()
451466 for _, want := range []string{"0", "temporary", "work", "running", "old", "stopped"} {
@@ -426,11 +426,26 @@ func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
426 }426 }
427 }427 }
428 428
429+func TestListShowsDegraded(t *testing.T) {
430+ var out bytes.Buffer
431+ printSessions(&out, []ports.SessionInfo{
432+ {Name: "fresh", State: ports.SessionStopped},
433+ {Name: "loading", State: ports.SessionRestoring},
434+ {Name: "broken", State: ports.SessionDegraded},
435+ })
436+ require.Contains(t, out.String(), "fresh")
437+ require.Contains(t, out.String(), "stopped")
438+ require.Contains(t, out.String(), "loading")
439+ require.Contains(t, out.String(), "restoring")
440+ require.Contains(t, out.String(), "broken")
441+ require.Contains(t, out.String(), "degraded")
442+}
443+
429 func TestPrintSessionsShowsStoppedState(t *testing.T) {444 func TestPrintSessionsShowsStoppedState(t *testing.T) {
430 var out bytes.Buffer445 var out bytes.Buffer
431 printSessions(&out, []ports.SessionInfo{446 printSessions(&out, []ports.SessionInfo{
432- {Name: "main", Tabs: 2, Attached: true},447+ {Name: "main", State: ports.SessionRunning, Tabs: 2, Attached: true},
433- {Name: "old", Stopped: true},448+ {Name: "old", State: ports.SessionStopped},
434 })449 })
435 got := out.String()450 got := out.String()
436 for _, want := range []string{"NAME", "STATE", "main", "running", "2", "yes", "old", "stopped", "-"} {451 for _, want := range []string{"NAME", "STATE", "main", "running", "2", "yes", "old", "stopped", "-"} {
@@ -443,9 +458,9 @@ func TestPrintSessionsShowsStoppedState(t *testing.T) {
443 func TestPrintSessionsMarksEphemeral(t *testing.T) {458 func TestPrintSessionsMarksEphemeral(t *testing.T) {
444 var buf bytes.Buffer459 var buf bytes.Buffer
445 printSessions(&buf, []ports.SessionInfo{460 printSessions(&buf, []ports.SessionInfo{
446- {Name: "0", Ephemeral: true, Tabs: 1, Attached: false},461+ {Name: "0", State: ports.SessionRunning, Ephemeral: true, Tabs: 1, Attached: false},
447- {Name: "work", Tabs: 2, Attached: true},462+ {Name: "work", State: ports.SessionRunning, Tabs: 2, Attached: true},
448- {Name: "old", Stopped: true},463+ {Name: "old", State: ports.SessionStopped},
449 })464 })
450 out := buf.String()465 out := buf.String()
451 for _, want := range []string{"0", "temporary", "work", "running", "old", "stopped"} {466 for _, want := range []string{"0", "temporary", "work", "running", "old", "stopped"} {
M internal/ports/frame.go +1 -1
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 18
4+const ProtocolVersion uint16 = 19
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 184+const ProtocolVersion uint16 = 19
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 18 {
7- t.Fatalf("ProtocolVersion = %d, want 18", ProtocolVersion)
6+ if ProtocolVersion != 19 {
7+ t.Fatalf("ProtocolVersion = %d, want 19", ProtocolVersion)
88 }
99 }
1010
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 18 {6+ if ProtocolVersion != 19 {
7- t.Fatalf("ProtocolVersion = %d, want 18", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 19", ProtocolVersion)
8 }8 }
9 }9 }
10 10
M internal/ports/wire.go +20 -9
@@ -49,7 +49,9 @@ const (
4949 ErrNotScriptable uint16 = 7
5050 ErrInvalidCommandArgs uint16 = 8
5151 ErrNoSuchTarget uint16 = 9
52- ErrAmbiguousTarget uint16 = 10
52+ ErrSessionRestoring uint16 = 10
53+ ErrSessionDegraded uint16 = 11
54+ ErrAmbiguousTarget uint16 = 12
5355 ErrInternal uint16 = 255
5456 )
5557
@@ -190,14 +192,24 @@ type Kill struct {
190192 All bool
191193 }
192194
195+// SessionState is the client-visible lifecycle state of a session.
196+type SessionState uint8
197+
198+const (
199+ SessionRunning SessionState = iota + 1
200+ SessionStopped
201+ SessionRestoring
202+ SessionDegraded
203+)
204+
193205 // SessionInfo describes one session in a Sessions listing.
194206 type SessionInfo struct {
195207 SessionID string
196208 Name string
209+ State SessionState
197210 Ephemeral bool
198211 Tabs uint16
199212 Attached bool
200- Stopped bool
201213 }
202214
203215 // Sessions is the daemon's reply to a List, enumerating live sessions.
@@ -875,11 +887,7 @@ func MarshalSessions(m Sessions) []byte {
875887 } else {
876888 w.putUint8(0)
877889 }
878- if s.Stopped {
879- w.putUint8(1)
880- } else {
881- w.putUint8(0)
882- }
890+ w.putUint8(uint8(s.State))
883891 }
884892 return w.b
885893 }
@@ -913,11 +921,14 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
913921 return Sessions{}, err
914922 }
915923 s.Attached = att != 0
916- stopped, err := r.getUint8()
924+ state, err := r.getUint8()
917925 if err != nil {
918926 return Sessions{}, err
919927 }
920- s.Stopped = stopped != 0
928+ s.State = SessionState(state)
929+ if s.State < SessionRunning || s.State > SessionDegraded {
930+ return Sessions{}, errors.New("ports: invalid session state")
931+ }
921932 sessions = append(sessions, s)
922933 }
923934 if err := r.done(); err != nil {
@@ -49,7 +49,9 @@ const (
49 ErrNotScriptable uint16 = 749 ErrNotScriptable uint16 = 7
50 ErrInvalidCommandArgs uint16 = 850 ErrInvalidCommandArgs uint16 = 8
51 ErrNoSuchTarget uint16 = 951 ErrNoSuchTarget uint16 = 9
52- ErrAmbiguousTarget uint16 = 1052+ ErrSessionRestoring uint16 = 10
53+ ErrSessionDegraded uint16 = 11
54+ ErrAmbiguousTarget uint16 = 12
53 ErrInternal uint16 = 25555 ErrInternal uint16 = 255
54 )56 )
55 57
@@ -190,14 +192,24 @@ type Kill struct {
190 All bool192 All bool
191 }193 }
192 194
195+// SessionState is the client-visible lifecycle state of a session.
196+type SessionState uint8
197+
198+const (
199+ SessionRunning SessionState = iota + 1
200+ SessionStopped
201+ SessionRestoring
202+ SessionDegraded
203+)
204+
193 // SessionInfo describes one session in a Sessions listing.205 // SessionInfo describes one session in a Sessions listing.
194 type SessionInfo struct {206 type SessionInfo struct {
195 SessionID string207 SessionID string
196 Name string208 Name string
209+ State SessionState
197 Ephemeral bool210 Ephemeral bool
198 Tabs uint16211 Tabs uint16
199 Attached bool212 Attached bool
200- Stopped bool
201 }213 }
202 214
203 // Sessions is the daemon's reply to a List, enumerating live sessions.215 // Sessions is the daemon's reply to a List, enumerating live sessions.
@@ -875,11 +887,7 @@ func MarshalSessions(m Sessions) []byte {
875 } else {887 } else {
876 w.putUint8(0)888 w.putUint8(0)
877 }889 }
878- if s.Stopped {890+ w.putUint8(uint8(s.State))
879- w.putUint8(1)
880- } else {
881- w.putUint8(0)
882- }
883 }891 }
884 return w.b892 return w.b
885 }893 }
@@ -913,11 +921,14 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
913 return Sessions{}, err921 return Sessions{}, err
914 }922 }
915 s.Attached = att != 0923 s.Attached = att != 0
916- stopped, err := r.getUint8()924+ state, err := r.getUint8()
917 if err != nil {925 if err != nil {
918 return Sessions{}, err926 return Sessions{}, err
919 }927 }
920- s.Stopped = stopped != 0928+ s.State = SessionState(state)
929+ if s.State < SessionRunning || s.State > SessionDegraded {
930+ return Sessions{}, errors.New("ports: invalid session state")
931+ }
921 sessions = append(sessions, s)932 sessions = append(sessions, s)
922 }933 }
923 if err := r.done(); err != nil {934 if err := r.done(); err != nil {
M internal/ports/wire_test.go +20 -9
@@ -122,7 +122,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
122122 Env: []string{"A=B", "XY=123"},
123123 })
124124 want := []byte{
125- 0x00, 0x12, 0x00, // version, intent
125+ 0x00, 0x13, 0x00, // version, intent
126126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
127127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
128128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -336,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
336336 }
337337 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
338338 require.Equal(t, want, MarshalTheme(cleared))
339- require.Equal(t, uint16(18), ProtocolVersion)
339+ require.Equal(t, uint16(19), ProtocolVersion)
340340 }
341341
342342 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -448,7 +448,9 @@ func TestCommandErrorCodes(t *testing.T) {
448448 require.Equal(t, uint16(7), ErrNotScriptable)
449449 require.Equal(t, uint16(8), ErrInvalidCommandArgs)
450450 require.Equal(t, uint16(9), ErrNoSuchTarget)
451- require.Equal(t, uint16(10), ErrAmbiguousTarget)
451+ require.Equal(t, uint16(10), ErrSessionRestoring)
452+ require.Equal(t, uint16(11), ErrSessionDegraded)
453+ require.Equal(t, uint16(12), ErrAmbiguousTarget)
452454 }
453455
454456 func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
@@ -461,7 +463,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
461463 name: "minimal",
462464 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
463465 want: []byte{
464- 0x00, 0x12,
466+ 0x00, 0x13,
465467 0x00,
466468 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
467469 0x00, 0x00,
@@ -482,7 +484,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
482484 JSON: true,
483485 },
484486 want: []byte{
485- 0x00, 0x12,
487+ 0x00, 0x13,
486488 0x01,
487489 0x00, 0x05, 't', 'o', 'a', 's', 't',
488490 0x00, 0x03,
@@ -741,6 +743,15 @@ func TestKillGoldenAndRoundTrip(t *testing.T) {
741743 assertTrailingGarbageFails(t, append(append([]byte(nil), legacy...), 0x00), UnmarshalKill)
742744 }
743745
746+func TestSessionInfoRecoveryState(t *testing.T) {
747+ payload := MarshalSessions(Sessions{Sessions: []SessionInfo{{Name: "broken", State: SessionDegraded}}})
748+ got, err := UnmarshalSessions(payload)
749+ require.NoError(t, err)
750+ require.Equal(t, SessionDegraded, got.Sessions[0].State)
751+ assertAllPrefixesFail(t, payload, UnmarshalSessions)
752+ assertTrailingGarbageFails(t, payload, UnmarshalSessions)
753+}
754+
744755 func TestSessionsGoldenAndRoundTrip(t *testing.T) {
745756 tests := []struct {
746757 name string
@@ -755,13 +766,13 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) {
755766 {
756767 name: "two",
757768 msg: Sessions{Sessions: []SessionInfo{
758- {SessionID: "0", Name: "0", Ephemeral: true, Tabs: 1, Attached: false},
759- {SessionID: "work", Name: "proj", Ephemeral: false, Tabs: 5, Attached: true, Stopped: true},
769+ {SessionID: "0", Name: "0", State: SessionRunning, Ephemeral: true, Tabs: 1, Attached: false},
770+ {SessionID: "work", Name: "proj", State: SessionStopped, Ephemeral: false, Tabs: 5, Attached: true},
760771 }},
761772 want: []byte{
762773 0x00, 0x02,
763- 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x00,
764- 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x01,
774+ 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x01,
775+ 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x02,
765776 },
766777 },
767778 }
@@ -122,7 +122,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
122 Env: []string{"A=B", "XY=123"},122 Env: []string{"A=B", "XY=123"},
123 })123 })
124 want := []byte{124 want := []byte{
125- 0x00, 0x12, 0x00, // version, intent125+ 0x00, 0x13, 0x00, // version, intent
126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -336,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
336 }336 }
337 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)337 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
338 require.Equal(t, want, MarshalTheme(cleared))338 require.Equal(t, want, MarshalTheme(cleared))
339- require.Equal(t, uint16(18), ProtocolVersion)339+ require.Equal(t, uint16(19), ProtocolVersion)
340 }340 }
341 341
342 func TestResizeGoldenAndRoundTrip(t *testing.T) {342 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -448,7 +448,9 @@ func TestCommandErrorCodes(t *testing.T) {
448 require.Equal(t, uint16(7), ErrNotScriptable)448 require.Equal(t, uint16(7), ErrNotScriptable)
449 require.Equal(t, uint16(8), ErrInvalidCommandArgs)449 require.Equal(t, uint16(8), ErrInvalidCommandArgs)
450 require.Equal(t, uint16(9), ErrNoSuchTarget)450 require.Equal(t, uint16(9), ErrNoSuchTarget)
451- require.Equal(t, uint16(10), ErrAmbiguousTarget)451+ require.Equal(t, uint16(10), ErrSessionRestoring)
452+ require.Equal(t, uint16(11), ErrSessionDegraded)
453+ require.Equal(t, uint16(12), ErrAmbiguousTarget)
452 }454 }
453 455
454 func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {456 func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
@@ -461,7 +463,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
461 name: "minimal",463 name: "minimal",
462 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},464 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
463 want: []byte{465 want: []byte{
464- 0x00, 0x12,466+ 0x00, 0x13,
465 0x00,467 0x00,
466 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',468 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
467 0x00, 0x00,469 0x00, 0x00,
@@ -482,7 +484,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
482 JSON: true,484 JSON: true,
483 },485 },
484 want: []byte{486 want: []byte{
485- 0x00, 0x12,487+ 0x00, 0x13,
486 0x01,488 0x01,
487 0x00, 0x05, 't', 'o', 'a', 's', 't',489 0x00, 0x05, 't', 'o', 'a', 's', 't',
488 0x00, 0x03,490 0x00, 0x03,
@@ -741,6 +743,15 @@ func TestKillGoldenAndRoundTrip(t *testing.T) {
741 assertTrailingGarbageFails(t, append(append([]byte(nil), legacy...), 0x00), UnmarshalKill)743 assertTrailingGarbageFails(t, append(append([]byte(nil), legacy...), 0x00), UnmarshalKill)
742 }744 }
743 745
746+func TestSessionInfoRecoveryState(t *testing.T) {
747+ payload := MarshalSessions(Sessions{Sessions: []SessionInfo{{Name: "broken", State: SessionDegraded}}})
748+ got, err := UnmarshalSessions(payload)
749+ require.NoError(t, err)
750+ require.Equal(t, SessionDegraded, got.Sessions[0].State)
751+ assertAllPrefixesFail(t, payload, UnmarshalSessions)
752+ assertTrailingGarbageFails(t, payload, UnmarshalSessions)
753+}
754+
744 func TestSessionsGoldenAndRoundTrip(t *testing.T) {755 func TestSessionsGoldenAndRoundTrip(t *testing.T) {
745 tests := []struct {756 tests := []struct {
746 name string757 name string
@@ -755,13 +766,13 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) {
755 {766 {
756 name: "two",767 name: "two",
757 msg: Sessions{Sessions: []SessionInfo{768 msg: Sessions{Sessions: []SessionInfo{
758- {SessionID: "0", Name: "0", Ephemeral: true, Tabs: 1, Attached: false},769+ {SessionID: "0", Name: "0", State: SessionRunning, Ephemeral: true, Tabs: 1, Attached: false},
759- {SessionID: "work", Name: "proj", Ephemeral: false, Tabs: 5, Attached: true, Stopped: true},770+ {SessionID: "work", Name: "proj", State: SessionStopped, Ephemeral: false, Tabs: 5, Attached: true},
760 }},771 }},
761 want: []byte{772 want: []byte{
762 0x00, 0x02,773 0x00, 0x02,
763- 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x00,774+ 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x01,
764- 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x01,775+ 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x02,
765 },776 },
766 },777 },
767 }778 }
M internal/usecase/daemon/daemon.go +53 -11
@@ -743,6 +743,7 @@ func (d *Daemon) handleList(tr ports.Transport) {
743743 info := ports.SessionInfo{
744744 SessionID: string(s.id),
745745 Name: s.name,
746+ State: ports.SessionRunning,
746747 Ephemeral: s.ephemeral,
747748 Tabs: uint16(len(s.tabs)),
748749 Attached: s.client != nil,
@@ -752,13 +753,20 @@ func (d *Daemon) handleList(tr ports.Transport) {
752753 infos = append(infos, info)
753754 }
754755 for name, stopped := range d.stopped {
755- if stopped.purging {
756- continue
757- }
758756 if _, live := liveNames[name]; live {
759757 continue
760758 }
761- infos = append(infos, ports.SessionInfo{Name: name, Stopped: true})
759+ state := ports.SessionStopped
760+ if stopped.purging {
761+ state = ports.SessionDegraded
762+ }
763+ switch stopped.state {
764+ case runtimeRestoring:
765+ state = ports.SessionRestoring
766+ case runtimeDegraded, runtimeDeleting:
767+ state = ports.SessionDegraded
768+ }
769+ infos = append(infos, ports.SessionInfo{Name: name, State: state})
762770 }
763771 d.mu.Unlock()
764772
@@ -895,6 +903,42 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
895903 return ac
896904 }
897905
906+func (d *Daemon) waitForTargetRestore(name string) error {
907+ for {
908+ d.mu.Lock()
909+ if d.findByNameLocked(name) != nil {
910+ d.mu.Unlock()
911+ return nil
912+ }
913+ stopped, ok := d.stopped[name]
914+ if !ok || stopped.purging {
915+ d.mu.Unlock()
916+ return nil
917+ }
918+ switch stopped.state {
919+ case runtimeRestoring:
920+ done := stopped.restoreDone
921+ var shutdown <-chan struct{}
922+ if d.serveCtx != nil {
923+ shutdown = d.serveCtx.Done()
924+ }
925+ d.mu.Unlock()
926+ select {
927+ case <-done:
928+ continue
929+ case <-shutdown:
930+ return &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
931+ }
932+ case runtimeDegraded, runtimeDeleting, runtimeHealthy:
933+ d.mu.Unlock()
934+ return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}
935+ default:
936+ d.mu.Unlock()
937+ return nil
938+ }
939+ }
940+}
941+
898942 // route resolves a Hello to a session and a freshly attached client, creating
899943 // the session for ephemeral/new intents.
900944 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
@@ -902,13 +946,6 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
902946 if !sz.Valid() {
903947 sz = defaultSize
904948 }
905- if d.snapsEnabled && (h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach || h.Intent == ports.IntentNew) {
906- select {
907- case <-d.restoreDone:
908- case <-d.serveCtx.Done():
909- return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
910- }
911- }
912949 term := terminalEnv{TrueColor: h.TrueColor}
913950
914951 if h.Intent == ports.IntentResume {
@@ -916,6 +953,11 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
916953 return sess, ac, err
917954 }
918955 }
956+ if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach {
957+ if err := d.waitForTargetRestore(h.Name); err != nil {
958+ return nil, nil, err
959+ }
960+ }
919961
920962 d.mu.Lock()
921963 // Shutdown/create interlock: once shutdown has begun (last session removed,
@@ -743,6 +743,7 @@ func (d *Daemon) handleList(tr ports.Transport) {
743 info := ports.SessionInfo{743 info := ports.SessionInfo{
744 SessionID: string(s.id),744 SessionID: string(s.id),
745 Name: s.name,745 Name: s.name,
746+ State: ports.SessionRunning,
746 Ephemeral: s.ephemeral,747 Ephemeral: s.ephemeral,
747 Tabs: uint16(len(s.tabs)),748 Tabs: uint16(len(s.tabs)),
748 Attached: s.client != nil,749 Attached: s.client != nil,
@@ -752,13 +753,20 @@ func (d *Daemon) handleList(tr ports.Transport) {
752 infos = append(infos, info)753 infos = append(infos, info)
753 }754 }
754 for name, stopped := range d.stopped {755 for name, stopped := range d.stopped {
755- if stopped.purging {
756- continue
757- }
758 if _, live := liveNames[name]; live {756 if _, live := liveNames[name]; live {
759 continue757 continue
760 }758 }
761- infos = append(infos, ports.SessionInfo{Name: name, Stopped: true})759+ state := ports.SessionStopped
760+ if stopped.purging {
761+ state = ports.SessionDegraded
762+ }
763+ switch stopped.state {
764+ case runtimeRestoring:
765+ state = ports.SessionRestoring
766+ case runtimeDegraded, runtimeDeleting:
767+ state = ports.SessionDegraded
768+ }
769+ infos = append(infos, ports.SessionInfo{Name: name, State: state})
762 }770 }
763 d.mu.Unlock()771 d.mu.Unlock()
764 772
@@ -895,6 +903,42 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
895 return ac903 return ac
896 }904 }
897 905
906+func (d *Daemon) waitForTargetRestore(name string) error {
907+ for {
908+ d.mu.Lock()
909+ if d.findByNameLocked(name) != nil {
910+ d.mu.Unlock()
911+ return nil
912+ }
913+ stopped, ok := d.stopped[name]
914+ if !ok || stopped.purging {
915+ d.mu.Unlock()
916+ return nil
917+ }
918+ switch stopped.state {
919+ case runtimeRestoring:
920+ done := stopped.restoreDone
921+ var shutdown <-chan struct{}
922+ if d.serveCtx != nil {
923+ shutdown = d.serveCtx.Done()
924+ }
925+ d.mu.Unlock()
926+ select {
927+ case <-done:
928+ continue
929+ case <-shutdown:
930+ return &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
931+ }
932+ case runtimeDegraded, runtimeDeleting, runtimeHealthy:
933+ d.mu.Unlock()
934+ return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}
935+ default:
936+ d.mu.Unlock()
937+ return nil
938+ }
939+ }
940+}
941+
898 // route resolves a Hello to a session and a freshly attached client, creating942 // route resolves a Hello to a session and a freshly attached client, creating
899 // the session for ephemeral/new intents.943 // the session for ephemeral/new intents.
900 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {944 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
@@ -902,13 +946,6 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
902 if !sz.Valid() {946 if !sz.Valid() {
903 sz = defaultSize947 sz = defaultSize
904 }948 }
905- if d.snapsEnabled && (h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach || h.Intent == ports.IntentNew) {
906- select {
907- case <-d.restoreDone:
908- case <-d.serveCtx.Done():
909- return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
910- }
911- }
912 term := terminalEnv{TrueColor: h.TrueColor}949 term := terminalEnv{TrueColor: h.TrueColor}
913 950
914 if h.Intent == ports.IntentResume {951 if h.Intent == ports.IntentResume {
@@ -916,6 +953,11 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
916 return sess, ac, err953 return sess, ac, err
917 }954 }
918 }955 }
956+ if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach {
957+ if err := d.waitForTargetRestore(h.Name); err != nil {
958+ return nil, nil, err
959+ }
960+ }
919 961
920 d.mu.Lock()962 d.mu.Lock()
921 // Shutdown/create interlock: once shutdown has begun (last session removed,963 // Shutdown/create interlock: once shutdown has begun (last session removed,
M internal/usecase/daemon/durable_recovery_test.go +72 -0
@@ -8,6 +8,7 @@ import (
88 "io"
99 "log/slog"
1010 "sync"
11+ "sync/atomic"
1112 "testing"
1213 "time"
1314
@@ -279,6 +280,77 @@ func TestCatalogueRestoreIncarnationMismatch(t *testing.T) {
279280 require.Equal(t, domain.RecoveryDegraded, got.RecoveryState)
280281 }
281282
283+type recoveryCountingPTYFactory struct {
284+ calls atomic.Int32
285+}
286+
287+func (f *recoveryCountingPTYFactory) Open(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error) {
288+ f.calls.Add(1)
289+ return nil, errors.New("unexpected PTY open")
290+}
291+
292+func TestListShowsDegraded(t *testing.T) {
293+ fresh := durableRecoveryRecord(0)
294+ fresh.RecoveryState = domain.RecoveryFresh
295+ fresh.Committed = nil
296+ restoring := durableRecoveryRecord(1)
297+ degraded := durableRecoveryRecord(2)
298+ degraded.RecoveryState = domain.RecoveryDegraded
299+ degraded.DegradedReason = "checkpoint validation failed"
300+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{fresh, restoring, degraded}, &durableRecoveryRepository{})
301+
302+ listed := listSessions(t, d)
303+ require.Equal(t, []ports.SessionInfo{
304+ {Name: fresh.Name, State: ports.SessionStopped},
305+ {Name: restoring.Name, State: ports.SessionRestoring},
306+ {Name: degraded.Name, State: ports.SessionDegraded},
307+ }, listed.Sessions)
308+}
309+
310+func TestAttachWaitsForRestore(t *testing.T) {
311+ record := durableRecoveryRecord(0)
312+ factory := &recoveryCountingPTYFactory{}
313+ d := New(factory, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}), []domain.CatalogueRecord{record}))
314+ d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
315+ defer d.serveCancel()
316+
317+ result := make(chan error, 1)
318+ go func() {
319+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil)
320+ result <- err
321+ }()
322+
323+ select {
324+ case err := <-result:
325+ t.Fatalf("attach returned before target restoration completed: %v", err)
326+ case <-time.After(25 * time.Millisecond):
327+ }
328+ require.Zero(t, factory.calls.Load())
329+
330+ d.setStoppedRecovery(record, runtimeDegraded)
331+ closeRuntimeRestoreDone(d.recordRestoreDone(record.Name))
332+ var protocolError *protoErr
333+ require.ErrorAs(t, <-result, &protocolError)
334+ require.Equal(t, ports.ErrSessionDegraded, protocolError.code)
335+ require.Zero(t, factory.calls.Load())
336+}
337+
338+func TestAttachRejectsDegraded(t *testing.T) {
339+ record := durableRecoveryRecord(0)
340+ record.RecoveryState = domain.RecoveryDegraded
341+ record.DegradedReason = "checkpoint validation failed"
342+ factory := &recoveryCountingPTYFactory{}
343+ d := New(factory, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}), []domain.CatalogueRecord{record}))
344+ d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
345+ defer d.serveCancel()
346+
347+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil)
348+ var protocolError *protoErr
349+ require.ErrorAs(t, err, &protocolError)
350+ require.Equal(t, ports.ErrSessionDegraded, protocolError.code)
351+ require.Zero(t, factory.calls.Load())
352+}
353+
282354 func TestCatalogueRestoreDegradedVisible(t *testing.T) {
283355 record := durableRecoveryRecord(0)
284356 record.RecoveryState = domain.RecoveryDegraded
@@ -8,6 +8,7 @@ import (
8 "io"8 "io"
9 "log/slog"9 "log/slog"
10 "sync"10 "sync"
11+ "sync/atomic"
11 "testing"12 "testing"
12 "time"13 "time"
13 14
@@ -279,6 +280,77 @@ func TestCatalogueRestoreIncarnationMismatch(t *testing.T) {
279 require.Equal(t, domain.RecoveryDegraded, got.RecoveryState)280 require.Equal(t, domain.RecoveryDegraded, got.RecoveryState)
280 }281 }
281 282
283+type recoveryCountingPTYFactory struct {
284+ calls atomic.Int32
285+}
286+
287+func (f *recoveryCountingPTYFactory) Open(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error) {
288+ f.calls.Add(1)
289+ return nil, errors.New("unexpected PTY open")
290+}
291+
292+func TestListShowsDegraded(t *testing.T) {
293+ fresh := durableRecoveryRecord(0)
294+ fresh.RecoveryState = domain.RecoveryFresh
295+ fresh.Committed = nil
296+ restoring := durableRecoveryRecord(1)
297+ degraded := durableRecoveryRecord(2)
298+ degraded.RecoveryState = domain.RecoveryDegraded
299+ degraded.DegradedReason = "checkpoint validation failed"
300+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{fresh, restoring, degraded}, &durableRecoveryRepository{})
301+
302+ listed := listSessions(t, d)
303+ require.Equal(t, []ports.SessionInfo{
304+ {Name: fresh.Name, State: ports.SessionStopped},
305+ {Name: restoring.Name, State: ports.SessionRestoring},
306+ {Name: degraded.Name, State: ports.SessionDegraded},
307+ }, listed.Sessions)
308+}
309+
310+func TestAttachWaitsForRestore(t *testing.T) {
311+ record := durableRecoveryRecord(0)
312+ factory := &recoveryCountingPTYFactory{}
313+ d := New(factory, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}), []domain.CatalogueRecord{record}))
314+ d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
315+ defer d.serveCancel()
316+
317+ result := make(chan error, 1)
318+ go func() {
319+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil)
320+ result <- err
321+ }()
322+
323+ select {
324+ case err := <-result:
325+ t.Fatalf("attach returned before target restoration completed: %v", err)
326+ case <-time.After(25 * time.Millisecond):
327+ }
328+ require.Zero(t, factory.calls.Load())
329+
330+ d.setStoppedRecovery(record, runtimeDegraded)
331+ closeRuntimeRestoreDone(d.recordRestoreDone(record.Name))
332+ var protocolError *protoErr
333+ require.ErrorAs(t, <-result, &protocolError)
334+ require.Equal(t, ports.ErrSessionDegraded, protocolError.code)
335+ require.Zero(t, factory.calls.Load())
336+}
337+
338+func TestAttachRejectsDegraded(t *testing.T) {
339+ record := durableRecoveryRecord(0)
340+ record.RecoveryState = domain.RecoveryDegraded
341+ record.DegradedReason = "checkpoint validation failed"
342+ factory := &recoveryCountingPTYFactory{}
343+ d := New(factory, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}), []domain.CatalogueRecord{record}))
344+ d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
345+ defer d.serveCancel()
346+
347+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil)
348+ var protocolError *protoErr
349+ require.ErrorAs(t, err, &protocolError)
350+ require.Equal(t, ports.ErrSessionDegraded, protocolError.code)
351+ require.Zero(t, factory.calls.Load())
352+}
353+
282 func TestCatalogueRestoreDegradedVisible(t *testing.T) {354 func TestCatalogueRestoreDegradedVisible(t *testing.T) {
283 record := durableRecoveryRecord(0)355 record := durableRecoveryRecord(0)
284 record.RecoveryState = domain.RecoveryDegraded356 record.RecoveryState = domain.RecoveryDegraded

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