feat(cli): expose durable session recovery state
- commit
- d19dd603fc36a25442e7200630b4717b75be0261
- author
- brice · · 2026-07-25 23:39 UTC
- parent
- 1b02243
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 1236 | 1236 | } | |
| 1237 | 1237 | infos := make([]ports.SessionInfo, 0, len(records)) | |
| 1238 | 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 | 1245 | printSessions(os.Stdout, infos) | |
| 1242 | 1246 | return nil | |
| @@ -1277,11 +1281,20 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||
| 1277 | 1281 | state := "running" | |
| 1278 | 1282 | tabs := fmt.Sprintf("%d", s.Tabs) | |
| 1279 | 1283 | attached := "no" | |
| 1280 | - | if s.Stopped { | |
| 1284 | + | switch s.State { | |
| 1285 | + | case ports.SessionStopped: | |
| 1281 | 1286 | state = "stopped" | |
| 1282 | 1287 | 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 | + | } | |
| 1285 | 1298 | } | |
| 1286 | 1299 | if s.Attached { | |
| 1287 | 1300 | 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 nil | 1246 | 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) { | |||
| 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 | 444 | func TestPrintSessionsShowsStoppedState(t *testing.T) { | |
| 430 | 445 | var out bytes.Buffer | |
| 431 | 446 | 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}, | |
| 434 | 449 | }) | |
| 435 | 450 | got := out.String() | |
| 436 | 451 | for _, want := range []string{"NAME", "STATE", "main", "running", "2", "yes", "old", "stopped", "-"} { | |
| @@ -443,9 +458,9 @@ func TestPrintSessionsShowsStoppedState(t *testing.T) { | |||
| 443 | 458 | func TestPrintSessionsMarksEphemeral(t *testing.T) { | |
| 444 | 459 | var buf bytes.Buffer | |
| 445 | 460 | 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}, | |
| 449 | 464 | }) | |
| 450 | 465 | out := buf.String() | |
| 451 | 466 | 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.Buffer | 445 | 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.Buffer | 459 | 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 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 18 | |
| 4 | + | const ProtocolVersion uint16 = 19 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | 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 = 18 | 4 | + | 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 uint8 | 7 | type MsgType uint8 | ||
M internal/ports/frame_test.go +2 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | 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) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -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 ( | |||
| 49 | 49 | ErrNotScriptable uint16 = 7 | |
| 50 | 50 | ErrInvalidCommandArgs uint16 = 8 | |
| 51 | 51 | ErrNoSuchTarget uint16 = 9 | |
| 52 | - | ErrAmbiguousTarget uint16 = 10 | |
| 52 | + | ErrSessionRestoring uint16 = 10 | |
| 53 | + | ErrSessionDegraded uint16 = 11 | |
| 54 | + | ErrAmbiguousTarget uint16 = 12 | |
| 53 | 55 | ErrInternal uint16 = 255 | |
| 54 | 56 | ) | |
| 55 | 57 | ||
| @@ -190,14 +192,24 @@ type Kill struct { | |||
| 190 | 192 | 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 | 205 | // SessionInfo describes one session in a Sessions listing. | |
| 194 | 206 | type SessionInfo struct { | |
| 195 | 207 | SessionID string | |
| 196 | 208 | Name string | |
| 209 | + | State SessionState | |
| 197 | 210 | Ephemeral bool | |
| 198 | 211 | Tabs uint16 | |
| 199 | 212 | Attached bool | |
| 200 | - | Stopped bool | |
| 201 | 213 | } | |
| 202 | 214 | ||
| 203 | 215 | // Sessions is the daemon's reply to a List, enumerating live sessions. | |
| @@ -875,11 +887,7 @@ func MarshalSessions(m Sessions) []byte { | |||
| 875 | 887 | } else { | |
| 876 | 888 | w.putUint8(0) | |
| 877 | 889 | } | |
| 878 | - | if s.Stopped { | |
| 879 | - | w.putUint8(1) | |
| 880 | - | } else { | |
| 881 | - | w.putUint8(0) | |
| 882 | - | } | |
| 890 | + | w.putUint8(uint8(s.State)) | |
| 883 | 891 | } | |
| 884 | 892 | return w.b | |
| 885 | 893 | } | |
| @@ -913,11 +921,14 @@ func UnmarshalSessions(b []byte) (Sessions, error) { | |||
| 913 | 921 | return Sessions{}, err | |
| 914 | 922 | } | |
| 915 | 923 | s.Attached = att != 0 | |
| 916 | - | stopped, err := r.getUint8() | |
| 924 | + | state, err := r.getUint8() | |
| 917 | 925 | if err != nil { | |
| 918 | 926 | return Sessions{}, err | |
| 919 | 927 | } | |
| 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 | + | } | |
| 921 | 932 | sessions = append(sessions, s) | |
| 922 | 933 | } | |
| 923 | 934 | if err := r.done(); err != nil { | |
| @@ -49,7 +49,9 @@ const ( | |||||
| 49 | ErrNotScriptable uint16 = 7 | 49 | ErrNotScriptable uint16 = 7 | ||
| 50 | ErrInvalidCommandArgs uint16 = 8 | 50 | ErrInvalidCommandArgs uint16 = 8 | ||
| 51 | ErrNoSuchTarget uint16 = 9 | 51 | ErrNoSuchTarget uint16 = 9 | ||
| 52 | - | ErrAmbiguousTarget uint16 = 10 | 52 | + | ErrSessionRestoring uint16 = 10 |
| 53 | + | ErrSessionDegraded uint16 = 11 | |||
| 54 | + | ErrAmbiguousTarget uint16 = 12 | |||
| 53 | ErrInternal uint16 = 255 | 55 | ErrInternal uint16 = 255 | ||
| 54 | ) | 56 | ) | ||
| 55 | 57 | ||||
| @@ -190,14 +192,24 @@ type Kill struct { | |||||
| 190 | All bool | 192 | 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 string | 207 | SessionID string | ||
| 196 | Name string | 208 | Name string | ||
| 209 | + | State SessionState | |||
| 197 | Ephemeral bool | 210 | Ephemeral bool | ||
| 198 | Tabs uint16 | 211 | Tabs uint16 | ||
| 199 | Attached bool | 212 | 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.b | 892 | return w.b | ||
| 885 | } | 893 | } | ||
| @@ -913,11 +921,14 @@ func UnmarshalSessions(b []byte) (Sessions, error) { | |||||
| 913 | return Sessions{}, err | 921 | return Sessions{}, err | ||
| 914 | } | 922 | } | ||
| 915 | s.Attached = att != 0 | 923 | 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{}, err | 926 | return Sessions{}, err | ||
| 919 | } | 927 | } | ||
| 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 | + | } | |||
| 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) { | |||
| 122 | 122 | Env: []string{"A=B", "XY=123"}, | |
| 123 | 123 | }) | |
| 124 | 124 | want := []byte{ | |
| 125 | - | 0x00, 0x12, 0x00, // version, intent | |
| 125 | + | 0x00, 0x13, 0x00, // version, intent | |
| 126 | 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 127 | 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 128 | 128 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -336,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 336 | 336 | } | |
| 337 | 337 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 338 | 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 | 342 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -448,7 +448,9 @@ func TestCommandErrorCodes(t *testing.T) { | |||
| 448 | 448 | require.Equal(t, uint16(7), ErrNotScriptable) | |
| 449 | 449 | require.Equal(t, uint16(8), ErrInvalidCommandArgs) | |
| 450 | 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 | 456 | func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |
| @@ -461,7 +463,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 461 | 463 | name: "minimal", | |
| 462 | 464 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |
| 463 | 465 | want: []byte{ | |
| 464 | - | 0x00, 0x12, | |
| 466 | + | 0x00, 0x13, | |
| 465 | 467 | 0x00, | |
| 466 | 468 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 467 | 469 | 0x00, 0x00, | |
| @@ -482,7 +484,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 482 | 484 | JSON: true, | |
| 483 | 485 | }, | |
| 484 | 486 | want: []byte{ | |
| 485 | - | 0x00, 0x12, | |
| 487 | + | 0x00, 0x13, | |
| 486 | 488 | 0x01, | |
| 487 | 489 | 0x00, 0x05, 't', 'o', 'a', 's', 't', | |
| 488 | 490 | 0x00, 0x03, | |
| @@ -741,6 +743,15 @@ func TestKillGoldenAndRoundTrip(t *testing.T) { | |||
| 741 | 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 | 755 | func TestSessionsGoldenAndRoundTrip(t *testing.T) { | |
| 745 | 756 | tests := []struct { | |
| 746 | 757 | name string | |
| @@ -755,13 +766,13 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) { | |||
| 755 | 766 | { | |
| 756 | 767 | name: "two", | |
| 757 | 768 | 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}, | |
| 760 | 771 | }}, | |
| 761 | 772 | want: []byte{ | |
| 762 | 773 | 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, | |
| 765 | 776 | }, | |
| 766 | 777 | }, | |
| 767 | 778 | } | |
| @@ -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, intent | 125 | + | 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 ID | 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 128 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 128 | 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 string | 757 | 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) { | |||
| 743 | 743 | info := ports.SessionInfo{ | |
| 744 | 744 | SessionID: string(s.id), | |
| 745 | 745 | Name: s.name, | |
| 746 | + | State: ports.SessionRunning, | |
| 746 | 747 | Ephemeral: s.ephemeral, | |
| 747 | 748 | Tabs: uint16(len(s.tabs)), | |
| 748 | 749 | Attached: s.client != nil, | |
| @@ -752,13 +753,20 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||
| 752 | 753 | infos = append(infos, info) | |
| 753 | 754 | } | |
| 754 | 755 | for name, stopped := range d.stopped { | |
| 755 | - | if stopped.purging { | |
| 756 | - | continue | |
| 757 | - | } | |
| 758 | 756 | if _, live := liveNames[name]; live { | |
| 759 | 757 | 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 | 771 | d.mu.Unlock() | |
| 764 | 772 | ||
| @@ -895,6 +903,42 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||
| 895 | 903 | 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 | 942 | // route resolves a Hello to a session and a freshly attached client, creating | |
| 899 | 943 | // the session for ephemeral/new intents. | |
| 900 | 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 | 946 | if !sz.Valid() { | |
| 903 | 947 | 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 | 949 | term := terminalEnv{TrueColor: h.TrueColor} | |
| 913 | 950 | ||
| 914 | 951 | if h.Intent == ports.IntentResume { | |
| @@ -916,6 +953,11 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 916 | 953 | 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 | 962 | d.mu.Lock() | |
| 921 | 963 | // 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 | continue | 757 | 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 ac | 903 | 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, creating | 942 | // 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 = defaultSize | 947 | 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, err | 953 | 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 ( | |||
| 8 | 8 | "io" | |
| 9 | 9 | "log/slog" | |
| 10 | 10 | "sync" | |
| 11 | + | "sync/atomic" | |
| 11 | 12 | "testing" | |
| 12 | 13 | "time" | |
| 13 | 14 | ||
| @@ -279,6 +280,77 @@ func TestCatalogueRestoreIncarnationMismatch(t *testing.T) { | |||
| 279 | 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 | 354 | func TestCatalogueRestoreDegradedVisible(t *testing.T) { | |
| 283 | 355 | record := durableRecoveryRecord(0) | |
| 284 | 356 | 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.RecoveryDegraded | 356 | record.RecoveryState = domain.RecoveryDegraded | ||