refactor(wire): collapse recovery states into one session state
- commit
- 84be71d178b20ff8fed5038347659cff7bb03483
- author
- brice · · 2026-07-26 11:57 UTC
- parent
- 5b0ff57
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
32 files · +242 -341
M internal/app/daemon_integration_test.go +3 -3
| @@ -493,7 +493,7 @@ func TestOfflineKillWaitsForLifecycleOwner(t *testing.T) { | |||
| 493 | 493 | t.Setenv("XDG_RUNTIME_DIR", runtimeRoot) | |
| 494 | 494 | p := newTestPersister(t, filepath.Join(stateRoot, "vev")) | |
| 495 | 495 | now := time.Now().UnixNano() | |
| 496 | - | require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh})) | |
| 496 | + | require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, CreatedAt: now, UpdatedAt: now})) | |
| 497 | 497 | require.NoError(t, p.Close()) | |
| 498 | 498 | owner, err := lifecycle.TryAcquire(ipc.SocketDir()) | |
| 499 | 499 | require.NoError(t, err) | |
| @@ -533,7 +533,7 @@ func TestLifecycleOwnershipOutlivesMaintenanceWriter(t *testing.T) { | |||
| 533 | 533 | Catalogue: newTestPersister(t, stateDir), | |
| 534 | 534 | closed: make(chan struct{}), | |
| 535 | 535 | } | |
| 536 | - | require.NoError(t, catalogue.Create(domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh})) | |
| 536 | + | require.NoError(t, catalogue.Create(domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}})) | |
| 537 | 537 | repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots")) | |
| 538 | 538 | shutdownClock := newLifecycleShutdownClock(t) | |
| 539 | 539 | maintenanceRepository := &lifecycleBlockingMaintenanceRepository{ | |
| @@ -764,7 +764,7 @@ func publishRestorableCheckpoint(t *testing.T, stateDir string, repository *snap | |||
| 764 | 764 | require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("checkpoint me\n")})})) | |
| 765 | 765 | require.Eventually(t, func() bool { | |
| 766 | 766 | record, ok, _ := opened.Catalogue.Record(name) | |
| 767 | - | return ok && record.RecoveryState == domain.RecoveryHealthy && record.Committed != nil | |
| 767 | + | return ok && record.Committed != nil && record.DegradedReason == "" | |
| 768 | 768 | }, 5*time.Second, 10*time.Millisecond, "session never committed a checkpoint") | |
| 769 | 769 | require.NoError(t, tr.Close()) | |
| 770 | 770 | ||
| @@ -493,7 +493,7 @@ func TestOfflineKillWaitsForLifecycleOwner(t *testing.T) { | |||||
| 493 | t.Setenv("XDG_RUNTIME_DIR", runtimeRoot) | 493 | t.Setenv("XDG_RUNTIME_DIR", runtimeRoot) | ||
| 494 | p := newTestPersister(t, filepath.Join(stateRoot, "vev")) | 494 | p := newTestPersister(t, filepath.Join(stateRoot, "vev")) | ||
| 495 | now := time.Now().UnixNano() | 495 | now := time.Now().UnixNano() | ||
| 496 | - | require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh})) | 496 | + | require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, CreatedAt: now, UpdatedAt: now})) |
| 497 | require.NoError(t, p.Close()) | 497 | require.NoError(t, p.Close()) | ||
| 498 | owner, err := lifecycle.TryAcquire(ipc.SocketDir()) | 498 | owner, err := lifecycle.TryAcquire(ipc.SocketDir()) | ||
| 499 | require.NoError(t, err) | 499 | require.NoError(t, err) | ||
| @@ -533,7 +533,7 @@ func TestLifecycleOwnershipOutlivesMaintenanceWriter(t *testing.T) { | |||||
| 533 | Catalogue: newTestPersister(t, stateDir), | 533 | Catalogue: newTestPersister(t, stateDir), | ||
| 534 | closed: make(chan struct{}), | 534 | closed: make(chan struct{}), | ||
| 535 | } | 535 | } | ||
| 536 | - | require.NoError(t, catalogue.Create(domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh})) | 536 | + | require.NoError(t, catalogue.Create(domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}})) |
| 537 | repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots")) | 537 | repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots")) | ||
| 538 | shutdownClock := newLifecycleShutdownClock(t) | 538 | shutdownClock := newLifecycleShutdownClock(t) | ||
| 539 | maintenanceRepository := &lifecycleBlockingMaintenanceRepository{ | 539 | maintenanceRepository := &lifecycleBlockingMaintenanceRepository{ | ||
| @@ -764,7 +764,7 @@ func publishRestorableCheckpoint(t *testing.T, stateDir string, repository *snap | |||||
| 764 | require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("checkpoint me\n")})})) | 764 | require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("checkpoint me\n")})})) | ||
| 765 | require.Eventually(t, func() bool { | 765 | require.Eventually(t, func() bool { | ||
| 766 | record, ok, _ := opened.Catalogue.Record(name) | 766 | record, ok, _ := opened.Catalogue.Record(name) | ||
| 767 | - | return ok && record.RecoveryState == domain.RecoveryHealthy && record.Committed != nil | 767 | + | return ok && record.Committed != nil && record.DegradedReason == "" |
| 768 | }, 5*time.Second, 10*time.Millisecond, "session never committed a checkpoint") | 768 | }, 5*time.Second, 10*time.Millisecond, "session never committed a checkpoint") | ||
| 769 | require.NoError(t, tr.Close()) | 769 | require.NoError(t, tr.Close()) | ||
| 770 | 770 | ||||
M internal/app/offline_kill_retry_test.go +2 -2
| @@ -21,7 +21,7 @@ func TestRunOfflineNamedKillUsesOpenCatalogueSeam(t *testing.T) { | |||
| 21 | 21 | ||
| 22 | 22 | p := newTestPersister(t, stateDir) | |
| 23 | 23 | now := time.Now().UnixNano() | |
| 24 | - | require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh})) | |
| 24 | + | require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now})) | |
| 25 | 25 | require.NoError(t, p.Close()) | |
| 26 | 26 | ||
| 27 | 27 | original := openCatalogue | |
| @@ -65,7 +65,7 @@ func TestRunKillOfflineUsesIncarnationDeletionProtocol(t *testing.T) { | |||
| 65 | 65 | id := domain.IncarnationID{1} | |
| 66 | 66 | p := newTestPersister(t, stateDir) | |
| 67 | 67 | now := time.Now().UnixNano() | |
| 68 | - | require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: id, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh})) | |
| 68 | + | require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: id, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now})) | |
| 69 | 69 | require.NoError(t, p.Close()) | |
| 70 | 70 | source := filepath.Join(stateDir, "snapshots", "sessions", id.String()) | |
| 71 | 71 | require.NoError(t, os.MkdirAll(source, 0o700)) | |
| @@ -21,7 +21,7 @@ func TestRunOfflineNamedKillUsesOpenCatalogueSeam(t *testing.T) { | |||||
| 21 | 21 | ||||
| 22 | p := newTestPersister(t, stateDir) | 22 | p := newTestPersister(t, stateDir) | ||
| 23 | now := time.Now().UnixNano() | 23 | now := time.Now().UnixNano() | ||
| 24 | - | require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh})) | 24 | + | require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now})) |
| 25 | require.NoError(t, p.Close()) | 25 | require.NoError(t, p.Close()) | ||
| 26 | 26 | ||||
| 27 | original := openCatalogue | 27 | original := openCatalogue | ||
| @@ -65,7 +65,7 @@ func TestRunKillOfflineUsesIncarnationDeletionProtocol(t *testing.T) { | |||||
| 65 | id := domain.IncarnationID{1} | 65 | id := domain.IncarnationID{1} | ||
| 66 | p := newTestPersister(t, stateDir) | 66 | p := newTestPersister(t, stateDir) | ||
| 67 | now := time.Now().UnixNano() | 67 | now := time.Now().UnixNano() | ||
| 68 | - | require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: id, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh})) | 68 | + | require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: id, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now})) |
| 69 | require.NoError(t, p.Close()) | 69 | require.NoError(t, p.Close()) | ||
| 70 | source := filepath.Join(stateDir, "snapshots", "sessions", id.String()) | 70 | source := filepath.Join(stateDir, "snapshots", "sessions", id.String()) | ||
| 71 | require.NoError(t, os.MkdirAll(source, 0o700)) | 71 | require.NoError(t, os.MkdirAll(source, 0o700)) | ||
M internal/app/run.go +12 -15
| @@ -505,18 +505,18 @@ func logCatalogueRecovery(log *slog.Logger, records []domain.CatalogueRecord, re | |||
| 505 | 505 | } | |
| 506 | 506 | ||
| 507 | 507 | func logStartupRecoveryCounts(log *slog.Logger, records []domain.CatalogueRecord, restoring int) { | |
| 508 | - | healthy, fresh, degraded := 0, 0, 0 | |
| 508 | + | healthy, fresh, broken := 0, 0, 0 | |
| 509 | 509 | for _, record := range records { | |
| 510 | - | switch record.RecoveryState { | |
| 511 | - | case domain.RecoveryHealthy: | |
| 512 | - | healthy++ | |
| 513 | - | case domain.RecoveryFresh: | |
| 510 | + | switch { | |
| 511 | + | case record.DegradedReason != "": | |
| 512 | + | broken++ | |
| 513 | + | case record.Committed == nil: | |
| 514 | 514 | fresh++ | |
| 515 | - | case domain.RecoveryDegraded: | |
| 516 | - | degraded++ | |
| 515 | + | default: | |
| 516 | + | healthy++ | |
| 517 | 517 | } | |
| 518 | 518 | } | |
| 519 | - | log.Info("daemon_startup_complete", "healthy", healthy, "fresh", fresh, "restoring", restoring, "degraded", degraded) | |
| 519 | + | log.Info("daemon_startup_complete", "healthy", healthy, "fresh", fresh, "restoring", restoring, "broken", broken) | |
| 520 | 520 | } | |
| 521 | 521 | ||
| 522 | 522 | func constructDaemonBeforeSocketPublication( | |
| @@ -1290,8 +1290,8 @@ func runList(ctx context.Context) (retErr error) { | |||
| 1290 | 1290 | infos := make([]ports.SessionInfo, 0, len(records)) | |
| 1291 | 1291 | for _, r := range records { | |
| 1292 | 1292 | state := ports.SessionStopped | |
| 1293 | - | if r.RecoveryState == domain.RecoveryDegraded || r.RecoveryState == domain.RecoveryDeleting { | |
| 1294 | - | state = ports.SessionDegraded | |
| 1293 | + | if r.DegradedReason != "" { | |
| 1294 | + | state = ports.SessionBroken | |
| 1295 | 1295 | } | |
| 1296 | 1296 | infos = append(infos, ports.SessionInfo{Name: r.Name, State: state}) | |
| 1297 | 1297 | } | |
| @@ -1338,11 +1338,8 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||
| 1338 | 1338 | case ports.SessionStopped: | |
| 1339 | 1339 | state = "stopped" | |
| 1340 | 1340 | tabs = "-" | |
| 1341 | - | case ports.SessionRestoring: | |
| 1342 | - | state = "restoring" | |
| 1343 | - | tabs = "-" | |
| 1344 | - | case ports.SessionDegraded: | |
| 1345 | - | state = "degraded" | |
| 1341 | + | case ports.SessionBroken: | |
| 1342 | + | state = "broken" | |
| 1346 | 1343 | tabs = "-" | |
| 1347 | 1344 | default: | |
| 1348 | 1345 | if s.Ephemeral { | |
| @@ -505,18 +505,18 @@ func logCatalogueRecovery(log *slog.Logger, records []domain.CatalogueRecord, re | |||||
| 505 | } | 505 | } | ||
| 506 | 506 | ||||
| 507 | func logStartupRecoveryCounts(log *slog.Logger, records []domain.CatalogueRecord, restoring int) { | 507 | func logStartupRecoveryCounts(log *slog.Logger, records []domain.CatalogueRecord, restoring int) { | ||
| 508 | - | healthy, fresh, degraded := 0, 0, 0 | 508 | + | healthy, fresh, broken := 0, 0, 0 |
| 509 | for _, record := range records { | 509 | for _, record := range records { | ||
| 510 | - | switch record.RecoveryState { | 510 | + | switch { |
| 511 | - | case domain.RecoveryHealthy: | 511 | + | case record.DegradedReason != "": |
| 512 | - | healthy++ | 512 | + | broken++ |
| 513 | - | case domain.RecoveryFresh: | 513 | + | case record.Committed == nil: |
| 514 | fresh++ | 514 | fresh++ | ||
| 515 | - | case domain.RecoveryDegraded: | 515 | + | default: |
| 516 | - | degraded++ | 516 | + | healthy++ |
| 517 | } | 517 | } | ||
| 518 | } | 518 | } | ||
| 519 | - | log.Info("daemon_startup_complete", "healthy", healthy, "fresh", fresh, "restoring", restoring, "degraded", degraded) | 519 | + | log.Info("daemon_startup_complete", "healthy", healthy, "fresh", fresh, "restoring", restoring, "broken", broken) |
| 520 | } | 520 | } | ||
| 521 | 521 | ||||
| 522 | func constructDaemonBeforeSocketPublication( | 522 | func constructDaemonBeforeSocketPublication( | ||
| @@ -1290,8 +1290,8 @@ func runList(ctx context.Context) (retErr error) { | |||||
| 1290 | infos := make([]ports.SessionInfo, 0, len(records)) | 1290 | infos := make([]ports.SessionInfo, 0, len(records)) | ||
| 1291 | for _, r := range records { | 1291 | for _, r := range records { | ||
| 1292 | state := ports.SessionStopped | 1292 | state := ports.SessionStopped | ||
| 1293 | - | if r.RecoveryState == domain.RecoveryDegraded || r.RecoveryState == domain.RecoveryDeleting { | 1293 | + | if r.DegradedReason != "" { |
| 1294 | - | state = ports.SessionDegraded | 1294 | + | state = ports.SessionBroken |
| 1295 | } | 1295 | } | ||
| 1296 | infos = append(infos, ports.SessionInfo{Name: r.Name, State: state}) | 1296 | infos = append(infos, ports.SessionInfo{Name: r.Name, State: state}) | ||
| 1297 | } | 1297 | } | ||
| @@ -1338,11 +1338,8 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) { | |||||
| 1338 | case ports.SessionStopped: | 1338 | case ports.SessionStopped: | ||
| 1339 | state = "stopped" | 1339 | state = "stopped" | ||
| 1340 | tabs = "-" | 1340 | tabs = "-" | ||
| 1341 | - | case ports.SessionRestoring: | 1341 | + | case ports.SessionBroken: |
| 1342 | - | state = "restoring" | 1342 | + | state = "broken" |
| 1343 | - | tabs = "-" | |||
| 1344 | - | case ports.SessionDegraded: | |||
| 1345 | - | state = "degraded" | |||
| 1346 | tabs = "-" | 1343 | tabs = "-" | ||
| 1347 | default: | 1344 | default: | ||
| 1348 | if s.Ephemeral { | 1345 | if s.Ephemeral { | ||
M internal/app/run_test.go +13 -12
| @@ -74,11 +74,12 @@ func TestRecoveryObservability(t *testing.T) { | |||
| 74 | 74 | }, | |
| 75 | 75 | log: log, | |
| 76 | 76 | } | |
| 77 | + | ref := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}} | |
| 77 | 78 | records := []domain.CatalogueRecord{ | |
| 78 | - | {RecoveryState: domain.RecoveryHealthy}, | |
| 79 | - | {RecoveryState: domain.RecoveryHealthy}, | |
| 80 | - | {RecoveryState: domain.RecoveryFresh}, | |
| 81 | - | {RecoveryState: domain.RecoveryDegraded}, | |
| 79 | + | {Committed: ref}, | |
| 80 | + | {Committed: ref}, | |
| 81 | + | {}, | |
| 82 | + | {Committed: ref, DegradedReason: "checkpoint validation failed"}, | |
| 82 | 83 | } | |
| 83 | 84 | require.NoError(t, runWithLifecycleOwnerDeps(context.Background(), "/runtime/vev", "/state/vev", func(context.Context) error { | |
| 84 | 85 | logCatalogueRecovery(log, records, "current") | |
| @@ -94,7 +95,7 @@ func TestRecoveryObservability(t *testing.T) { | |||
| 94 | 95 | require.EqualValues(t, 2, startup["healthy"]) | |
| 95 | 96 | require.EqualValues(t, 1, startup["fresh"]) | |
| 96 | 97 | require.EqualValues(t, 0, startup["restoring"]) | |
| 97 | - | require.EqualValues(t, 1, startup["degraded"]) | |
| 98 | + | require.EqualValues(t, 1, startup["broken"]) | |
| 98 | 99 | require.NotContains(t, eventNames(entries), "interrupted_transaction_recovery_complete") | |
| 99 | 100 | } | |
| 100 | 101 | ||
| @@ -640,19 +641,19 @@ func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) { | |||
| 640 | 641 | } | |
| 641 | 642 | } | |
| 642 | 643 | ||
| 643 | - | func TestListShowsDegraded(t *testing.T) { | |
| 644 | + | func TestListShowsBroken(t *testing.T) { | |
| 644 | 645 | var out bytes.Buffer | |
| 645 | 646 | printSessions(&out, []ports.SessionInfo{ | |
| 646 | 647 | {Name: "fresh", State: ports.SessionStopped}, | |
| 647 | - | {Name: "loading", State: ports.SessionRestoring}, | |
| 648 | - | {Name: "broken", State: ports.SessionDegraded}, | |
| 648 | + | {Name: "loading", State: ports.SessionStopped}, | |
| 649 | + | {Name: "broken", State: ports.SessionBroken}, | |
| 649 | 650 | }) | |
| 650 | 651 | require.Contains(t, out.String(), "fresh") | |
| 651 | 652 | require.Contains(t, out.String(), "stopped") | |
| 652 | 653 | require.Contains(t, out.String(), "loading") | |
| 653 | - | require.Contains(t, out.String(), "restoring") | |
| 654 | 654 | require.Contains(t, out.String(), "broken") | |
| 655 | - | require.Contains(t, out.String(), "degraded") | |
| 655 | + | require.NotContains(t, out.String(), "restoring") | |
| 656 | + | require.NotContains(t, out.String(), "degraded") | |
| 656 | 657 | } | |
| 657 | 658 | ||
| 658 | 659 | func TestPrintSessionsShowsStoppedState(t *testing.T) { | |
| @@ -696,7 +697,7 @@ func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) { | |||
| 696 | 697 | ||
| 697 | 698 | p := newTestPersister(t, filepath.Join(stateRoot, "vev")) | |
| 698 | 699 | now := time.Now().UnixNano() | |
| 699 | - | if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}); err != nil { | |
| 700 | + | if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil { | |
| 700 | 701 | t.Fatalf("Save error = %v", err) | |
| 701 | 702 | } | |
| 702 | 703 | if err := p.Close(); err != nil { | |
| @@ -736,7 +737,7 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) { | |||
| 736 | 737 | ||
| 737 | 738 | p := newTestPersister(t, filepath.Join(stateRoot, "vev")) | |
| 738 | 739 | now := time.Now().UnixNano() | |
| 739 | - | if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}); err != nil { | |
| 740 | + | if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil { | |
| 740 | 741 | t.Fatalf("Save error = %v", err) | |
| 741 | 742 | } | |
| 742 | 743 | if err := p.Close(); err != nil { | |
| @@ -74,11 +74,12 @@ func TestRecoveryObservability(t *testing.T) { | |||||
| 74 | }, | 74 | }, | ||
| 75 | log: log, | 75 | log: log, | ||
| 76 | } | 76 | } | ||
| 77 | + | ref := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}} | |||
| 77 | records := []domain.CatalogueRecord{ | 78 | records := []domain.CatalogueRecord{ | ||
| 78 | - | {RecoveryState: domain.RecoveryHealthy}, | 79 | + | {Committed: ref}, |
| 79 | - | {RecoveryState: domain.RecoveryHealthy}, | 80 | + | {Committed: ref}, |
| 80 | - | {RecoveryState: domain.RecoveryFresh}, | 81 | + | {}, |
| 81 | - | {RecoveryState: domain.RecoveryDegraded}, | 82 | + | {Committed: ref, DegradedReason: "checkpoint validation failed"}, |
| 82 | } | 83 | } | ||
| 83 | require.NoError(t, runWithLifecycleOwnerDeps(context.Background(), "/runtime/vev", "/state/vev", func(context.Context) error { | 84 | require.NoError(t, runWithLifecycleOwnerDeps(context.Background(), "/runtime/vev", "/state/vev", func(context.Context) error { | ||
| 84 | logCatalogueRecovery(log, records, "current") | 85 | logCatalogueRecovery(log, records, "current") | ||
| @@ -94,7 +95,7 @@ func TestRecoveryObservability(t *testing.T) { | |||||
| 94 | require.EqualValues(t, 2, startup["healthy"]) | 95 | require.EqualValues(t, 2, startup["healthy"]) | ||
| 95 | require.EqualValues(t, 1, startup["fresh"]) | 96 | require.EqualValues(t, 1, startup["fresh"]) | ||
| 96 | require.EqualValues(t, 0, startup["restoring"]) | 97 | require.EqualValues(t, 0, startup["restoring"]) | ||
| 97 | - | require.EqualValues(t, 1, startup["degraded"]) | 98 | + | require.EqualValues(t, 1, startup["broken"]) |
| 98 | require.NotContains(t, eventNames(entries), "interrupted_transaction_recovery_complete") | 99 | require.NotContains(t, eventNames(entries), "interrupted_transaction_recovery_complete") | ||
| 99 | } | 100 | } | ||
| 100 | 101 | ||||
| @@ -640,19 +641,19 @@ func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) { | |||||
| 640 | } | 641 | } | ||
| 641 | } | 642 | } | ||
| 642 | 643 | ||||
| 643 | - | func TestListShowsDegraded(t *testing.T) { | 644 | + | func TestListShowsBroken(t *testing.T) { |
| 644 | var out bytes.Buffer | 645 | var out bytes.Buffer | ||
| 645 | printSessions(&out, []ports.SessionInfo{ | 646 | printSessions(&out, []ports.SessionInfo{ | ||
| 646 | {Name: "fresh", State: ports.SessionStopped}, | 647 | {Name: "fresh", State: ports.SessionStopped}, | ||
| 647 | - | {Name: "loading", State: ports.SessionRestoring}, | 648 | + | {Name: "loading", State: ports.SessionStopped}, |
| 648 | - | {Name: "broken", State: ports.SessionDegraded}, | 649 | + | {Name: "broken", State: ports.SessionBroken}, |
| 649 | }) | 650 | }) | ||
| 650 | require.Contains(t, out.String(), "fresh") | 651 | require.Contains(t, out.String(), "fresh") | ||
| 651 | require.Contains(t, out.String(), "stopped") | 652 | require.Contains(t, out.String(), "stopped") | ||
| 652 | require.Contains(t, out.String(), "loading") | 653 | require.Contains(t, out.String(), "loading") | ||
| 653 | - | require.Contains(t, out.String(), "restoring") | |||
| 654 | require.Contains(t, out.String(), "broken") | 654 | require.Contains(t, out.String(), "broken") | ||
| 655 | - | require.Contains(t, out.String(), "degraded") | 655 | + | require.NotContains(t, out.String(), "restoring") |
| 656 | + | require.NotContains(t, out.String(), "degraded") | |||
| 656 | } | 657 | } | ||
| 657 | 658 | ||||
| 658 | func TestPrintSessionsShowsStoppedState(t *testing.T) { | 659 | func TestPrintSessionsShowsStoppedState(t *testing.T) { | ||
| @@ -696,7 +697,7 @@ func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) { | |||||
| 696 | 697 | ||||
| 697 | p := newTestPersister(t, filepath.Join(stateRoot, "vev")) | 698 | p := newTestPersister(t, filepath.Join(stateRoot, "vev")) | ||
| 698 | now := time.Now().UnixNano() | 699 | now := time.Now().UnixNano() | ||
| 699 | - | if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}); err != nil { | 700 | + | if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil { |
| 700 | t.Fatalf("Save error = %v", err) | 701 | t.Fatalf("Save error = %v", err) | ||
| 701 | } | 702 | } | ||
| 702 | if err := p.Close(); err != nil { | 703 | if err := p.Close(); err != nil { | ||
| @@ -736,7 +737,7 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) { | |||||
| 736 | 737 | ||||
| 737 | p := newTestPersister(t, filepath.Join(stateRoot, "vev")) | 738 | p := newTestPersister(t, filepath.Join(stateRoot, "vev")) | ||
| 738 | now := time.Now().UnixNano() | 739 | now := time.Now().UnixNano() | ||
| 739 | - | if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}); err != nil { | 740 | + | if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil { |
| 740 | t.Fatalf("Save error = %v", err) | 741 | t.Fatalf("Save error = %v", err) | ||
| 741 | } | 742 | } | ||
| 742 | if err := p.Close(); err != nil { | 743 | if err := p.Close(); err != nil { | ||
M internal/domain/recovery.go +4 -37
| @@ -12,16 +12,6 @@ import ( | |||
| 12 | 12 | // IncarnationID permanently distinguishes uses of the same session name. | |
| 13 | 13 | type IncarnationID [16]byte | |
| 14 | 14 | ||
| 15 | - | // RecoveryState describes whether and how a named session can be restored. | |
| 16 | - | type RecoveryState uint8 | |
| 17 | - | ||
| 18 | - | const ( | |
| 19 | - | RecoveryFresh RecoveryState = iota + 1 | |
| 20 | - | RecoveryHealthy | |
| 21 | - | RecoveryDegraded | |
| 22 | - | RecoveryDeleting | |
| 23 | - | ) | |
| 24 | - | ||
| 25 | 15 | // CheckpointRef identifies one immutable snapshot manifest. | |
| 26 | 16 | type CheckpointRef struct { | |
| 27 | 17 | Generation uint64 | |
| @@ -37,7 +27,6 @@ type CatalogueRecord struct { | |||
| 37 | 27 | UpdatedAt int64 | |
| 38 | 28 | LastUsedSeq uint64 | |
| 39 | 29 | TabNames []string | |
| 40 | - | RecoveryState RecoveryState | |
| 41 | 30 | Committed *CheckpointRef | |
| 42 | 31 | DegradedReason string | |
| 43 | 32 | } | |
| @@ -55,8 +44,8 @@ func (r *CheckpointRef) Equal(other *CheckpointRef) bool { | |||
| 55 | 44 | func (r CatalogueRecord) Equal(other CatalogueRecord) bool { | |
| 56 | 45 | return r.Name == other.Name && r.IncarnationID == other.IncarnationID && r.Cwd == other.Cwd && | |
| 57 | 46 | r.CreatedAt == other.CreatedAt && r.UpdatedAt == other.UpdatedAt && r.LastUsedSeq == other.LastUsedSeq && | |
| 58 | - | slices.Equal(r.TabNames, other.TabNames) && r.RecoveryState == other.RecoveryState && | |
| 59 | - | r.Committed.Equal(other.Committed) && r.DegradedReason == other.DegradedReason | |
| 47 | + | slices.Equal(r.TabNames, other.TabNames) && r.Committed.Equal(other.Committed) && | |
| 48 | + | r.DegradedReason == other.DegradedReason | |
| 60 | 49 | } | |
| 61 | 50 | ||
| 62 | 51 | // CatalogueMetadataUpdate changes mutable runtime metadata for one catalogue | |
| @@ -130,30 +119,8 @@ func (r CatalogueRecord) Validate() error { | |||
| 130 | 119 | return errors.New("zero catalogue incarnation ID") | |
| 131 | 120 | } | |
| 132 | 121 | ||
| 133 | - | switch r.RecoveryState { | |
| 134 | - | case RecoveryFresh: | |
| 135 | - | if r.Committed != nil { | |
| 136 | - | return errors.New("fresh session has checkpoint reference") | |
| 137 | - | } | |
| 138 | - | case RecoveryHealthy: | |
| 139 | - | if r.Committed == nil { | |
| 140 | - | return errors.New("healthy session has no committed checkpoint") | |
| 141 | - | } | |
| 142 | - | case RecoveryDegraded: | |
| 143 | - | if r.Committed == nil { | |
| 144 | - | return errors.New("degraded session has no committed checkpoint") | |
| 145 | - | } | |
| 146 | - | if r.DegradedReason == "" { | |
| 147 | - | return errors.New("degraded session has no reason") | |
| 148 | - | } | |
| 149 | - | case RecoveryDeleting: | |
| 150 | - | // A fresh or checkpointed session may be deleted. | |
| 151 | - | default: | |
| 152 | - | return errors.New("invalid recovery state") | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | if r.RecoveryState != RecoveryDegraded && r.DegradedReason != "" { | |
| 156 | - | return errors.New("non-degraded session has degraded reason") | |
| 122 | + | if r.DegradedReason != "" && r.Committed == nil { | |
| 123 | + | return errors.New("broken session has no committed checkpoint") | |
| 157 | 124 | } | |
| 158 | 125 | if r.Committed != nil { | |
| 159 | 126 | if r.Committed.Generation == 0 { | |
| @@ -12,16 +12,6 @@ import ( | |||||
| 12 | // IncarnationID permanently distinguishes uses of the same session name. | 12 | // IncarnationID permanently distinguishes uses of the same session name. | ||
| 13 | type IncarnationID [16]byte | 13 | type IncarnationID [16]byte | ||
| 14 | 14 | ||||
| 15 | - | // RecoveryState describes whether and how a named session can be restored. | |||
| 16 | - | type RecoveryState uint8 | |||
| 17 | - | ||||
| 18 | - | const ( | |||
| 19 | - | RecoveryFresh RecoveryState = iota + 1 | |||
| 20 | - | RecoveryHealthy | |||
| 21 | - | RecoveryDegraded | |||
| 22 | - | RecoveryDeleting | |||
| 23 | - | ) | |||
| 24 | - | ||||
| 25 | // CheckpointRef identifies one immutable snapshot manifest. | 15 | // CheckpointRef identifies one immutable snapshot manifest. | ||
| 26 | type CheckpointRef struct { | 16 | type CheckpointRef struct { | ||
| 27 | Generation uint64 | 17 | Generation uint64 | ||
| @@ -37,7 +27,6 @@ type CatalogueRecord struct { | |||||
| 37 | UpdatedAt int64 | 27 | UpdatedAt int64 | ||
| 38 | LastUsedSeq uint64 | 28 | LastUsedSeq uint64 | ||
| 39 | TabNames []string | 29 | TabNames []string | ||
| 40 | - | RecoveryState RecoveryState | |||
| 41 | Committed *CheckpointRef | 30 | Committed *CheckpointRef | ||
| 42 | DegradedReason string | 31 | DegradedReason string | ||
| 43 | } | 32 | } | ||
| @@ -55,8 +44,8 @@ func (r *CheckpointRef) Equal(other *CheckpointRef) bool { | |||||
| 55 | func (r CatalogueRecord) Equal(other CatalogueRecord) bool { | 44 | func (r CatalogueRecord) Equal(other CatalogueRecord) bool { | ||
| 56 | return r.Name == other.Name && r.IncarnationID == other.IncarnationID && r.Cwd == other.Cwd && | 45 | return r.Name == other.Name && r.IncarnationID == other.IncarnationID && r.Cwd == other.Cwd && | ||
| 57 | r.CreatedAt == other.CreatedAt && r.UpdatedAt == other.UpdatedAt && r.LastUsedSeq == other.LastUsedSeq && | 46 | r.CreatedAt == other.CreatedAt && r.UpdatedAt == other.UpdatedAt && r.LastUsedSeq == other.LastUsedSeq && | ||
| 58 | - | slices.Equal(r.TabNames, other.TabNames) && r.RecoveryState == other.RecoveryState && | 47 | + | slices.Equal(r.TabNames, other.TabNames) && r.Committed.Equal(other.Committed) && |
| 59 | - | r.Committed.Equal(other.Committed) && r.DegradedReason == other.DegradedReason | 48 | + | r.DegradedReason == other.DegradedReason |
| 60 | } | 49 | } | ||
| 61 | 50 | ||||
| 62 | // CatalogueMetadataUpdate changes mutable runtime metadata for one catalogue | 51 | // CatalogueMetadataUpdate changes mutable runtime metadata for one catalogue | ||
| @@ -130,30 +119,8 @@ func (r CatalogueRecord) Validate() error { | |||||
| 130 | return errors.New("zero catalogue incarnation ID") | 119 | return errors.New("zero catalogue incarnation ID") | ||
| 131 | } | 120 | } | ||
| 132 | 121 | ||||
| 133 | - | switch r.RecoveryState { | 122 | + | if r.DegradedReason != "" && r.Committed == nil { |
| 134 | - | case RecoveryFresh: | 123 | + | return errors.New("broken session has no committed checkpoint") |
| 135 | - | if r.Committed != nil { | |||
| 136 | - | return errors.New("fresh session has checkpoint reference") | |||
| 137 | - | } | |||
| 138 | - | case RecoveryHealthy: | |||
| 139 | - | if r.Committed == nil { | |||
| 140 | - | return errors.New("healthy session has no committed checkpoint") | |||
| 141 | - | } | |||
| 142 | - | case RecoveryDegraded: | |||
| 143 | - | if r.Committed == nil { | |||
| 144 | - | return errors.New("degraded session has no committed checkpoint") | |||
| 145 | - | } | |||
| 146 | - | if r.DegradedReason == "" { | |||
| 147 | - | return errors.New("degraded session has no reason") | |||
| 148 | - | } | |||
| 149 | - | case RecoveryDeleting: | |||
| 150 | - | // A fresh or checkpointed session may be deleted. | |||
| 151 | - | default: | |||
| 152 | - | return errors.New("invalid recovery state") | |||
| 153 | - | } | |||
| 154 | - | ||||
| 155 | - | if r.RecoveryState != RecoveryDegraded && r.DegradedReason != "" { | |||
| 156 | - | return errors.New("non-degraded session has degraded reason") | |||
| 157 | } | 124 | } | ||
| 158 | if r.Committed != nil { | 125 | if r.Committed != nil { | ||
| 159 | if r.Committed.Generation == 0 { | 126 | if r.Committed.Generation == 0 { | ||
M internal/domain/recovery_test.go +9 -13
| @@ -37,18 +37,14 @@ func TestCatalogueRecordValidate(t *testing.T) { | |||
| 37 | 37 | record CatalogueRecord | |
| 38 | 38 | wantValid bool | |
| 39 | 39 | }{ | |
| 40 | - | {name: "fresh valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}, wantValid: true}, | |
| 41 | - | {name: "healthy with committed checkpoint valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}}, wantValid: true}, | |
| 42 | - | {name: "invalid session name", record: CatalogueRecord{Name: "bad/name", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}}, | |
| 43 | - | {name: "zero incarnation", record: CatalogueRecord{Name: "work", RecoveryState: RecoveryFresh}}, | |
| 44 | - | {name: "unknown recovery state", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryState(99)}}, | |
| 45 | - | {name: "fresh with committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh, Committed: validRef}}, | |
| 46 | - | {name: "healthy without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy}}, | |
| 47 | - | {name: "degraded without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryDegraded, DegradedReason: "missing manifest"}}, | |
| 48 | - | {name: "degraded without reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryDegraded, Committed: validRef}}, | |
| 49 | - | {name: "healthy with degraded reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, DegradedReason: "missing manifest"}}, | |
| 50 | - | {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}}, | |
| 51 | - | {name: "zero checkpoint digest", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7}}}, | |
| 40 | + | {name: "fresh valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}}, wantValid: true}, | |
| 41 | + | {name: "healthy with committed checkpoint valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}}, wantValid: true}, | |
| 42 | + | {name: "invalid session name", record: CatalogueRecord{Name: "bad/name", IncarnationID: IncarnationID{1}}}, | |
| 43 | + | {name: "zero incarnation", record: CatalogueRecord{Name: "work"}}, | |
| 44 | + | {name: "broken with committed checkpoint valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: validRef, DegradedReason: "missing manifest"}, wantValid: true}, | |
| 45 | + | {name: "broken without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, DegradedReason: "missing manifest"}}, | |
| 46 | + | {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}}, | |
| 47 | + | {name: "zero checkpoint digest", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: &CheckpointRef{Generation: 7}}}, | |
| 52 | 48 | } | |
| 53 | 49 | for _, tt := range tests { | |
| 54 | 50 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -64,7 +60,7 @@ func TestCatalogueRecordValidate(t *testing.T) { | |||
| 64 | 60 | ||
| 65 | 61 | func TestCatalogueRecordEqualUsesCheckpointValuesAndOrderedTabs(t *testing.T) { | |
| 66 | 62 | committed := CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}} | |
| 67 | - | left := CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, TabNames: []string{"shell", "logs"}, RecoveryState: RecoveryHealthy, Committed: &committed} | |
| 63 | + | left := CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, TabNames: []string{"shell", "logs"}, Committed: &committed} | |
| 68 | 64 | right := left | |
| 69 | 65 | right.TabNames = append([]string(nil), left.TabNames...) | |
| 70 | 66 | committedCopy := committed | |
| @@ -37,18 +37,14 @@ func TestCatalogueRecordValidate(t *testing.T) { | |||||
| 37 | record CatalogueRecord | 37 | record CatalogueRecord | ||
| 38 | wantValid bool | 38 | wantValid bool | ||
| 39 | }{ | 39 | }{ | ||
| 40 | - | {name: "fresh valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}, wantValid: true}, | 40 | + | {name: "fresh valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}}, wantValid: true}, |
| 41 | - | {name: "healthy with committed checkpoint valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}}, wantValid: true}, | 41 | + | {name: "healthy with committed checkpoint valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}}, wantValid: true}, |
| 42 | - | {name: "invalid session name", record: CatalogueRecord{Name: "bad/name", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}}, | 42 | + | {name: "invalid session name", record: CatalogueRecord{Name: "bad/name", IncarnationID: IncarnationID{1}}}, |
| 43 | - | {name: "zero incarnation", record: CatalogueRecord{Name: "work", RecoveryState: RecoveryFresh}}, | 43 | + | {name: "zero incarnation", record: CatalogueRecord{Name: "work"}}, |
| 44 | - | {name: "unknown recovery state", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryState(99)}}, | 44 | + | {name: "broken with committed checkpoint valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: validRef, DegradedReason: "missing manifest"}, wantValid: true}, |
| 45 | - | {name: "fresh with committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh, Committed: validRef}}, | 45 | + | {name: "broken without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, DegradedReason: "missing manifest"}}, |
| 46 | - | {name: "healthy without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy}}, | 46 | + | {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}}, |
| 47 | - | {name: "degraded without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryDegraded, DegradedReason: "missing manifest"}}, | 47 | + | {name: "zero checkpoint digest", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: &CheckpointRef{Generation: 7}}}, |
| 48 | - | {name: "degraded without reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryDegraded, Committed: validRef}}, | |||
| 49 | - | {name: "healthy with degraded reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, DegradedReason: "missing manifest"}}, | |||
| 50 | - | {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}}, | |||
| 51 | - | {name: "zero checkpoint digest", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7}}}, | |||
| 52 | } | 48 | } | ||
| 53 | for _, tt := range tests { | 49 | for _, tt := range tests { | ||
| 54 | t.Run(tt.name, func(t *testing.T) { | 50 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -64,7 +60,7 @@ func TestCatalogueRecordValidate(t *testing.T) { | |||||
| 64 | 60 | ||||
| 65 | func TestCatalogueRecordEqualUsesCheckpointValuesAndOrderedTabs(t *testing.T) { | 61 | func TestCatalogueRecordEqualUsesCheckpointValuesAndOrderedTabs(t *testing.T) { | ||
| 66 | committed := CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}} | 62 | committed := CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}} | ||
| 67 | - | left := CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, TabNames: []string{"shell", "logs"}, RecoveryState: RecoveryHealthy, Committed: &committed} | 63 | + | left := CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, TabNames: []string{"shell", "logs"}, Committed: &committed} |
| 68 | right := left | 64 | right := left | ||
| 69 | right.TabNames = append([]string(nil), left.TabNames...) | 65 | right.TabNames = append([]string(nil), left.TabNames...) | ||
| 70 | committedCopy := committed | 66 | committedCopy := committed | ||
M internal/persist/catalogue_test.go +12 -13
| @@ -25,10 +25,10 @@ func TestCatalogue(t *testing.T) { | |||
| 25 | 25 | func testCatalogueRecordRoundTrip(t *testing.T) { | |
| 26 | 26 | ref1 := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}} | |
| 27 | 27 | tests := []domain.CatalogueRecord{ | |
| 28 | - | {Name: "fresh", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", RecoveryState: domain.RecoveryFresh}, | |
| 29 | - | {Name: "healthy", IncarnationID: domain.IncarnationID{2}, RecoveryState: domain.RecoveryHealthy, Committed: ref1}, | |
| 30 | - | {Name: "degraded", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDegraded, Committed: ref1, DegradedReason: "invalid manifest"}, | |
| 31 | - | {Name: "deleting", IncarnationID: domain.IncarnationID{5}, RecoveryState: domain.RecoveryDeleting}, | |
| 28 | + | {Name: "fresh", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp"}, | |
| 29 | + | {Name: "healthy", IncarnationID: domain.IncarnationID{2}, Committed: ref1}, | |
| 30 | + | {Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: ref1, DegradedReason: "invalid manifest"}, | |
| 31 | + | {Name: "other-fresh", IncarnationID: domain.IncarnationID{5}}, | |
| 32 | 32 | } | |
| 33 | 33 | for _, record := range tests { | |
| 34 | 34 | t.Run(record.Name, func(t *testing.T) { | |
| @@ -46,17 +46,16 @@ func testCatalogueRecordRoundTrip(t *testing.T) { | |||
| 46 | 46 | } | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | - | func TestDecodeRecordValueWrapsMalformedAndValidationErrors(t *testing.T) { | |
| 49 | + | func TestDecodeRecordValueRejectsMalformedCheckpointMarker(t *testing.T) { | |
| 50 | 50 | encoded, err := encodeRecordValue(validRecord("work", 1)) | |
| 51 | 51 | require.NoError(t, err) | |
| 52 | - | encoded[recoveryStateFieldOffset(t, encoded)] = 99 | |
| 52 | + | encoded[checkpointMarkerOffset(t, encoded)] = 99 | |
| 53 | 53 | ||
| 54 | 54 | _, err = decodeRecordValue("work", encoded) | |
| 55 | 55 | require.ErrorIs(t, err, errMalformedRecord) | |
| 56 | - | require.ErrorContains(t, err, "invalid recovery state") | |
| 57 | 56 | } | |
| 58 | 57 | ||
| 59 | - | func recoveryStateFieldOffset(t *testing.T, encoded []byte) int { | |
| 58 | + | func checkpointMarkerOffset(t *testing.T, encoded []byte) int { | |
| 60 | 59 | t.Helper() | |
| 61 | 60 | r := valueReader{data: encoded} | |
| 62 | 61 | _, ok := r.take(len(catalogueMagic) + 2 + len(domain.IncarnationID{})) | |
| @@ -77,8 +76,8 @@ func recoveryStateFieldOffset(t *testing.T, encoded []byte) int { | |||
| 77 | 76 | func testCatalogueDuplicateIncarnations(t *testing.T) { | |
| 78 | 77 | id := domain.IncarnationID{1} | |
| 79 | 78 | require.Error(t, validateUniqueIncarnations([]domain.CatalogueRecord{ | |
| 80 | - | {Name: "one", IncarnationID: id, RecoveryState: domain.RecoveryFresh}, | |
| 81 | - | {Name: "two", IncarnationID: id, RecoveryState: domain.RecoveryFresh}, | |
| 79 | + | {Name: "one", IncarnationID: id}, | |
| 80 | + | {Name: "two", IncarnationID: id}, | |
| 82 | 81 | })) | |
| 83 | 82 | } | |
| 84 | 83 | ||
| @@ -105,8 +104,8 @@ func testCatalogueApplyRenameReplace(t *testing.T) { | |||
| 105 | 104 | require.Empty(t, records) | |
| 106 | 105 | defer func() { require.NoError(t, p.Close()) }() | |
| 107 | 106 | ||
| 108 | - | one := domain.CatalogueRecord{Name: "one", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh} | |
| 109 | - | two := domain.CatalogueRecord{Name: "two", IncarnationID: domain.IncarnationID{2}, RecoveryState: domain.RecoveryFresh} | |
| 107 | + | one := domain.CatalogueRecord{Name: "one", IncarnationID: domain.IncarnationID{1}} | |
| 108 | + | two := domain.CatalogueRecord{Name: "two", IncarnationID: domain.IncarnationID{2}} | |
| 110 | 109 | require.NoError(t, p.Apply(map[string]*domain.CatalogueRecord{"one": &one, "two": &two})) | |
| 111 | 110 | renamed := one | |
| 112 | 111 | renamed.Name = "renamed" | |
| @@ -129,7 +128,7 @@ func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) { | |||
| 129 | 128 | original := domain.CatalogueRecord{ | |
| 130 | 129 | Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old", CreatedAt: 11, | |
| 131 | 130 | UpdatedAt: 12, LastUsedSeq: 13, TabNames: []string{"old"}, | |
| 132 | - | RecoveryState: domain.RecoveryDegraded, Committed: committed, DegradedReason: "repair pending", | |
| 131 | + | Committed: committed, DegradedReason: "repair pending", | |
| 133 | 132 | } | |
| 134 | 133 | require.NoError(t, p.Create(original)) | |
| 135 | 134 | ||
| @@ -25,10 +25,10 @@ func TestCatalogue(t *testing.T) { | |||||
| 25 | func testCatalogueRecordRoundTrip(t *testing.T) { | 25 | func testCatalogueRecordRoundTrip(t *testing.T) { | ||
| 26 | ref1 := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}} | 26 | ref1 := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}} | ||
| 27 | tests := []domain.CatalogueRecord{ | 27 | tests := []domain.CatalogueRecord{ | ||
| 28 | - | {Name: "fresh", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", RecoveryState: domain.RecoveryFresh}, | 28 | + | {Name: "fresh", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp"}, |
| 29 | - | {Name: "healthy", IncarnationID: domain.IncarnationID{2}, RecoveryState: domain.RecoveryHealthy, Committed: ref1}, | 29 | + | {Name: "healthy", IncarnationID: domain.IncarnationID{2}, Committed: ref1}, |
| 30 | - | {Name: "degraded", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDegraded, Committed: ref1, DegradedReason: "invalid manifest"}, | 30 | + | {Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: ref1, DegradedReason: "invalid manifest"}, |
| 31 | - | {Name: "deleting", IncarnationID: domain.IncarnationID{5}, RecoveryState: domain.RecoveryDeleting}, | 31 | + | {Name: "other-fresh", IncarnationID: domain.IncarnationID{5}}, |
| 32 | } | 32 | } | ||
| 33 | for _, record := range tests { | 33 | for _, record := range tests { | ||
| 34 | t.Run(record.Name, func(t *testing.T) { | 34 | t.Run(record.Name, func(t *testing.T) { | ||
| @@ -46,17 +46,16 @@ func testCatalogueRecordRoundTrip(t *testing.T) { | |||||
| 46 | } | 46 | } | ||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | - | func TestDecodeRecordValueWrapsMalformedAndValidationErrors(t *testing.T) { | 49 | + | func TestDecodeRecordValueRejectsMalformedCheckpointMarker(t *testing.T) { |
| 50 | encoded, err := encodeRecordValue(validRecord("work", 1)) | 50 | encoded, err := encodeRecordValue(validRecord("work", 1)) | ||
| 51 | require.NoError(t, err) | 51 | require.NoError(t, err) | ||
| 52 | - | encoded[recoveryStateFieldOffset(t, encoded)] = 99 | 52 | + | encoded[checkpointMarkerOffset(t, encoded)] = 99 |
| 53 | 53 | ||||
| 54 | _, err = decodeRecordValue("work", encoded) | 54 | _, err = decodeRecordValue("work", encoded) | ||
| 55 | require.ErrorIs(t, err, errMalformedRecord) | 55 | require.ErrorIs(t, err, errMalformedRecord) | ||
| 56 | - | require.ErrorContains(t, err, "invalid recovery state") | |||
| 57 | } | 56 | } | ||
| 58 | 57 | ||||
| 59 | - | func recoveryStateFieldOffset(t *testing.T, encoded []byte) int { | 58 | + | func checkpointMarkerOffset(t *testing.T, encoded []byte) int { |
| 60 | t.Helper() | 59 | t.Helper() | ||
| 61 | r := valueReader{data: encoded} | 60 | r := valueReader{data: encoded} | ||
| 62 | _, ok := r.take(len(catalogueMagic) + 2 + len(domain.IncarnationID{})) | 61 | _, ok := r.take(len(catalogueMagic) + 2 + len(domain.IncarnationID{})) | ||
| @@ -77,8 +76,8 @@ func recoveryStateFieldOffset(t *testing.T, encoded []byte) int { | |||||
| 77 | func testCatalogueDuplicateIncarnations(t *testing.T) { | 76 | func testCatalogueDuplicateIncarnations(t *testing.T) { | ||
| 78 | id := domain.IncarnationID{1} | 77 | id := domain.IncarnationID{1} | ||
| 79 | require.Error(t, validateUniqueIncarnations([]domain.CatalogueRecord{ | 78 | require.Error(t, validateUniqueIncarnations([]domain.CatalogueRecord{ | ||
| 80 | - | {Name: "one", IncarnationID: id, RecoveryState: domain.RecoveryFresh}, | 79 | + | {Name: "one", IncarnationID: id}, |
| 81 | - | {Name: "two", IncarnationID: id, RecoveryState: domain.RecoveryFresh}, | 80 | + | {Name: "two", IncarnationID: id}, |
| 82 | })) | 81 | })) | ||
| 83 | } | 82 | } | ||
| 84 | 83 | ||||
| @@ -105,8 +104,8 @@ func testCatalogueApplyRenameReplace(t *testing.T) { | |||||
| 105 | require.Empty(t, records) | 104 | require.Empty(t, records) | ||
| 106 | defer func() { require.NoError(t, p.Close()) }() | 105 | defer func() { require.NoError(t, p.Close()) }() | ||
| 107 | 106 | ||||
| 108 | - | one := domain.CatalogueRecord{Name: "one", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh} | 107 | + | one := domain.CatalogueRecord{Name: "one", IncarnationID: domain.IncarnationID{1}} |
| 109 | - | two := domain.CatalogueRecord{Name: "two", IncarnationID: domain.IncarnationID{2}, RecoveryState: domain.RecoveryFresh} | 108 | + | two := domain.CatalogueRecord{Name: "two", IncarnationID: domain.IncarnationID{2}} |
| 110 | require.NoError(t, p.Apply(map[string]*domain.CatalogueRecord{"one": &one, "two": &two})) | 109 | require.NoError(t, p.Apply(map[string]*domain.CatalogueRecord{"one": &one, "two": &two})) | ||
| 111 | renamed := one | 110 | renamed := one | ||
| 112 | renamed.Name = "renamed" | 111 | renamed.Name = "renamed" | ||
| @@ -129,7 +128,7 @@ func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) { | |||||
| 129 | original := domain.CatalogueRecord{ | 128 | original := domain.CatalogueRecord{ | ||
| 130 | Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old", CreatedAt: 11, | 129 | Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old", CreatedAt: 11, | ||
| 131 | UpdatedAt: 12, LastUsedSeq: 13, TabNames: []string{"old"}, | 130 | UpdatedAt: 12, LastUsedSeq: 13, TabNames: []string{"old"}, | ||
| 132 | - | RecoveryState: domain.RecoveryDegraded, Committed: committed, DegradedReason: "repair pending", | 131 | + | Committed: committed, DegradedReason: "repair pending", |
| 133 | } | 132 | } | ||
| 134 | require.NoError(t, p.Create(original)) | 133 | require.NoError(t, p.Create(original)) | ||
| 135 | 134 | ||||
M internal/persist/persist_test.go +1 -1
| @@ -16,7 +16,7 @@ import ( | |||
| 16 | 16 | ||
| 17 | 17 | func privateDir(t *testing.T) string { t.Helper(); return filepath.Join(t.TempDir(), "vev") } | |
| 18 | 18 | func validRecord(name string, id byte) domain.CatalogueRecord { | |
| 19 | - | return domain.CatalogueRecord{Name: name, IncarnationID: domain.IncarnationID{id}, RecoveryState: domain.RecoveryFresh} | |
| 19 | + | return domain.CatalogueRecord{Name: name, IncarnationID: domain.IncarnationID{id}} | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | func TestOpenOrCreate(t *testing.T) { | |
| @@ -16,7 +16,7 @@ import ( | |||||
| 16 | 16 | ||||
| 17 | func privateDir(t *testing.T) string { t.Helper(); return filepath.Join(t.TempDir(), "vev") } | 17 | func privateDir(t *testing.T) string { t.Helper(); return filepath.Join(t.TempDir(), "vev") } | ||
| 18 | func validRecord(name string, id byte) domain.CatalogueRecord { | 18 | func validRecord(name string, id byte) domain.CatalogueRecord { | ||
| 19 | - | return domain.CatalogueRecord{Name: name, IncarnationID: domain.IncarnationID{id}, RecoveryState: domain.RecoveryFresh} | 19 | + | return domain.CatalogueRecord{Name: name, IncarnationID: domain.IncarnationID{id}} |
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | func TestOpenOrCreate(t *testing.T) { | 22 | func TestOpenOrCreate(t *testing.T) { | ||
M internal/persist/record.go +1 -7
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | - | const catalogueRecordVersion uint16 = 2 | |
| 12 | + | const catalogueRecordVersion uint16 = 3 | |
| 13 | 13 | ||
| 14 | 14 | var errMalformedRecord = errors.New("persist: malformed catalogue record") | |
| 15 | 15 | ||
| @@ -44,7 +44,6 @@ func encodeRecordValue(record domain.CatalogueRecord) ([]byte, error) { | |||
| 44 | 44 | return nil, err | |
| 45 | 45 | } | |
| 46 | 46 | } | |
| 47 | - | buf = append(buf, byte(record.RecoveryState)) | |
| 48 | 47 | buf = appendRef(buf, record.Committed) | |
| 49 | 48 | buf, err = appendCheckedString(buf, record.DegradedReason) | |
| 50 | 49 | return buf, err | |
| @@ -100,11 +99,6 @@ func decodeRecordValue(name string, value []byte) (domain.CatalogueRecord, error | |||
| 100 | 99 | } | |
| 101 | 100 | record.TabNames = append(record.TabNames, tab) | |
| 102 | 101 | } | |
| 103 | - | state, ok := reader.byte() | |
| 104 | - | if !ok { | |
| 105 | - | return domain.CatalogueRecord{}, errMalformedRecord | |
| 106 | - | } | |
| 107 | - | record.RecoveryState = domain.RecoveryState(state) | |
| 108 | 102 | if record.Committed, ok = reader.ref(); !ok { | |
| 109 | 103 | return domain.CatalogueRecord{}, errMalformedRecord | |
| 110 | 104 | } | |
| @@ -9,7 +9,7 @@ import ( | |||||
| 9 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | - | const catalogueRecordVersion uint16 = 2 | 12 | + | const catalogueRecordVersion uint16 = 3 |
| 13 | 13 | ||||
| 14 | var errMalformedRecord = errors.New("persist: malformed catalogue record") | 14 | var errMalformedRecord = errors.New("persist: malformed catalogue record") | ||
| 15 | 15 | ||||
| @@ -44,7 +44,6 @@ func encodeRecordValue(record domain.CatalogueRecord) ([]byte, error) { | |||||
| 44 | return nil, err | 44 | return nil, err | ||
| 45 | } | 45 | } | ||
| 46 | } | 46 | } | ||
| 47 | - | buf = append(buf, byte(record.RecoveryState)) | |||
| 48 | buf = appendRef(buf, record.Committed) | 47 | buf = appendRef(buf, record.Committed) | ||
| 49 | buf, err = appendCheckedString(buf, record.DegradedReason) | 48 | buf, err = appendCheckedString(buf, record.DegradedReason) | ||
| 50 | return buf, err | 49 | return buf, err | ||
| @@ -100,11 +99,6 @@ func decodeRecordValue(name string, value []byte) (domain.CatalogueRecord, error | |||||
| 100 | } | 99 | } | ||
| 101 | record.TabNames = append(record.TabNames, tab) | 100 | record.TabNames = append(record.TabNames, tab) | ||
| 102 | } | 101 | } | ||
| 103 | - | state, ok := reader.byte() | |||
| 104 | - | if !ok { | |||
| 105 | - | return domain.CatalogueRecord{}, errMalformedRecord | |||
| 106 | - | } | |||
| 107 | - | record.RecoveryState = domain.RecoveryState(state) | |||
| 108 | if record.Committed, ok = reader.ref(); !ok { | 102 | if record.Committed, ok = reader.ref(); !ok { | ||
| 109 | return domain.CatalogueRecord{}, errMalformedRecord | 103 | return domain.CatalogueRecord{}, errMalformedRecord | ||
| 110 | } | 104 | } | ||
M internal/ports/wire.go +4 -7
| @@ -49,9 +49,7 @@ const ( | |||
| 49 | 49 | ErrNotScriptable uint16 = 7 | |
| 50 | 50 | ErrInvalidCommandArgs uint16 = 8 | |
| 51 | 51 | ErrNoSuchTarget uint16 = 9 | |
| 52 | - | ErrSessionRestoring uint16 = 10 | |
| 53 | - | ErrSessionDegraded uint16 = 11 | |
| 54 | - | ErrAmbiguousTarget uint16 = 12 | |
| 52 | + | ErrAmbiguousTarget uint16 = 10 | |
| 55 | 53 | ErrInternal uint16 = 255 | |
| 56 | 54 | ) | |
| 57 | 55 | ||
| @@ -196,10 +194,9 @@ type Kill struct { | |||
| 196 | 194 | type SessionState uint8 | |
| 197 | 195 | ||
| 198 | 196 | const ( | |
| 199 | - | SessionRunning SessionState = iota + 1 | |
| 197 | + | SessionRunning SessionState = iota | |
| 200 | 198 | SessionStopped | |
| 201 | - | SessionRestoring | |
| 202 | - | SessionDegraded | |
| 199 | + | SessionBroken | |
| 203 | 200 | ) | |
| 204 | 201 | ||
| 205 | 202 | // SessionInfo describes one session in a Sessions listing. | |
| @@ -926,7 +923,7 @@ func UnmarshalSessions(b []byte) (Sessions, error) { | |||
| 926 | 923 | return Sessions{}, err | |
| 927 | 924 | } | |
| 928 | 925 | s.State = SessionState(state) | |
| 929 | - | if s.State < SessionRunning || s.State > SessionDegraded { | |
| 926 | + | if s.State > SessionBroken { | |
| 930 | 927 | return Sessions{}, errors.New("ports: invalid session state") | |
| 931 | 928 | } | |
| 932 | 929 | sessions = append(sessions, s) | |
| @@ -49,9 +49,7 @@ 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 | - | ErrSessionRestoring uint16 = 10 | 52 | + | ErrAmbiguousTarget uint16 = 10 |
| 53 | - | ErrSessionDegraded uint16 = 11 | |||
| 54 | - | ErrAmbiguousTarget uint16 = 12 | |||
| 55 | ErrInternal uint16 = 255 | 53 | ErrInternal uint16 = 255 | ||
| 56 | ) | 54 | ) | ||
| 57 | 55 | ||||
| @@ -196,10 +194,9 @@ type Kill struct { | |||||
| 196 | type SessionState uint8 | 194 | type SessionState uint8 | ||
| 197 | 195 | ||||
| 198 | const ( | 196 | const ( | ||
| 199 | - | SessionRunning SessionState = iota + 1 | 197 | + | SessionRunning SessionState = iota |
| 200 | SessionStopped | 198 | SessionStopped | ||
| 201 | - | SessionRestoring | 199 | + | SessionBroken |
| 202 | - | SessionDegraded | |||
| 203 | ) | 200 | ) | ||
| 204 | 201 | ||||
| 205 | // SessionInfo describes one session in a Sessions listing. | 202 | // SessionInfo describes one session in a Sessions listing. | ||
| @@ -926,7 +923,7 @@ func UnmarshalSessions(b []byte) (Sessions, error) { | |||||
| 926 | return Sessions{}, err | 923 | return Sessions{}, err | ||
| 927 | } | 924 | } | ||
| 928 | s.State = SessionState(state) | 925 | s.State = SessionState(state) | ||
| 929 | - | if s.State < SessionRunning || s.State > SessionDegraded { | 926 | + | if s.State > SessionBroken { |
| 930 | return Sessions{}, errors.New("ports: invalid session state") | 927 | return Sessions{}, errors.New("ports: invalid session state") | ||
| 931 | } | 928 | } | ||
| 932 | sessions = append(sessions, s) | 929 | sessions = append(sessions, s) | ||
M internal/ports/wire_test.go +7 -10
| @@ -448,9 +448,7 @@ 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), ErrSessionRestoring) | |
| 452 | - | require.Equal(t, uint16(11), ErrSessionDegraded) | |
| 453 | - | require.Equal(t, uint16(12), ErrAmbiguousTarget) | |
| 451 | + | require.Equal(t, uint16(10), ErrAmbiguousTarget) | |
| 454 | 452 | } | |
| 455 | 453 | ||
| 456 | 454 | func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |
| @@ -749,10 +747,9 @@ func TestSessionInfoRecoveryState(t *testing.T) { | |||
| 749 | 747 | state SessionState | |
| 750 | 748 | want []byte | |
| 751 | 749 | }{ | |
| 752 | - | {name: "running", state: SessionRunning, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1}}, | |
| 753 | - | {name: "stopped", state: SessionStopped, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2}}, | |
| 754 | - | {name: "restoring", state: SessionRestoring, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 3}}, | |
| 755 | - | {name: "degraded", state: SessionDegraded, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 4}}, | |
| 750 | + | {name: "running", state: SessionRunning, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0}}, | |
| 751 | + | {name: "stopped", state: SessionStopped, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1}}, | |
| 752 | + | {name: "broken", state: SessionBroken, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2}}, | |
| 756 | 753 | } | |
| 757 | 754 | for _, tt := range tests { | |
| 758 | 755 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -765,7 +762,7 @@ func TestSessionInfoRecoveryState(t *testing.T) { | |||
| 765 | 762 | assertTrailingGarbageFails(t, payload, UnmarshalSessions) | |
| 766 | 763 | }) | |
| 767 | 764 | } | |
| 768 | - | for _, state := range []byte{0, 5} { | |
| 765 | + | for _, state := range []byte{3, 255} { | |
| 769 | 766 | payload := append([]byte(nil), tests[0].want...) | |
| 770 | 767 | payload[len(payload)-1] = state | |
| 771 | 768 | _, err := UnmarshalSessions(payload) | |
| @@ -792,8 +789,8 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) { | |||
| 792 | 789 | }}, | |
| 793 | 790 | want: []byte{ | |
| 794 | 791 | 0x00, 0x02, | |
| 795 | - | 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x01, | |
| 796 | - | 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x02, | |
| 792 | + | 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x00, | |
| 793 | + | 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x01, | |
| 797 | 794 | }, | |
| 798 | 795 | }, | |
| 799 | 796 | } | |
| @@ -448,9 +448,7 @@ 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), ErrSessionRestoring) | 451 | + | require.Equal(t, uint16(10), ErrAmbiguousTarget) |
| 452 | - | require.Equal(t, uint16(11), ErrSessionDegraded) | |||
| 453 | - | require.Equal(t, uint16(12), ErrAmbiguousTarget) | |||
| 454 | } | 452 | } | ||
| 455 | 453 | ||||
| 456 | func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | 454 | func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -749,10 +747,9 @@ func TestSessionInfoRecoveryState(t *testing.T) { | |||||
| 749 | state SessionState | 747 | state SessionState | ||
| 750 | want []byte | 748 | want []byte | ||
| 751 | }{ | 749 | }{ | ||
| 752 | - | {name: "running", state: SessionRunning, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1}}, | 750 | + | {name: "running", state: SessionRunning, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0}}, |
| 753 | - | {name: "stopped", state: SessionStopped, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2}}, | 751 | + | {name: "stopped", state: SessionStopped, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1}}, |
| 754 | - | {name: "restoring", state: SessionRestoring, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 3}}, | 752 | + | {name: "broken", state: SessionBroken, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2}}, |
| 755 | - | {name: "degraded", state: SessionDegraded, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 4}}, | |||
| 756 | } | 753 | } | ||
| 757 | for _, tt := range tests { | 754 | for _, tt := range tests { | ||
| 758 | t.Run(tt.name, func(t *testing.T) { | 755 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -765,7 +762,7 @@ func TestSessionInfoRecoveryState(t *testing.T) { | |||||
| 765 | assertTrailingGarbageFails(t, payload, UnmarshalSessions) | 762 | assertTrailingGarbageFails(t, payload, UnmarshalSessions) | ||
| 766 | }) | 763 | }) | ||
| 767 | } | 764 | } | ||
| 768 | - | for _, state := range []byte{0, 5} { | 765 | + | for _, state := range []byte{3, 255} { |
| 769 | payload := append([]byte(nil), tests[0].want...) | 766 | payload := append([]byte(nil), tests[0].want...) | ||
| 770 | payload[len(payload)-1] = state | 767 | payload[len(payload)-1] = state | ||
| 771 | _, err := UnmarshalSessions(payload) | 768 | _, err := UnmarshalSessions(payload) | ||
| @@ -792,8 +789,8 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) { | |||||
| 792 | }}, | 789 | }}, | ||
| 793 | want: []byte{ | 790 | want: []byte{ | ||
| 794 | 0x00, 0x02, | 791 | 0x00, 0x02, | ||
| 795 | - | 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x01, | 792 | + | 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x00, |
| 796 | - | 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x02, | 793 | + | 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x01, |
| 797 | }, | 794 | }, | ||
| 798 | }, | 795 | }, | ||
| 799 | } | 796 | } | ||
M internal/usecase/daemon/coderabbit_regression_test.go +2 -3
| @@ -26,14 +26,13 @@ func TestRestoreTransientLoadFailureBecomesDiscardable(t *testing.T) { | |||
| 26 | 26 | persisted, ok, err := catalogue.Record(record.Name) | |
| 27 | 27 | require.NoError(t, err) | |
| 28 | 28 | require.True(t, ok) | |
| 29 | - | require.Equal(t, domain.RecoveryDegraded, persisted.RecoveryState) | |
| 30 | 29 | require.Equal(t, "checkpoint load failed", persisted.DegradedReason) | |
| 31 | 30 | d.mu.Lock() | |
| 32 | 31 | entry := d.stopped[record.Name] | |
| 33 | 32 | d.mu.Unlock() | |
| 34 | - | require.Equal(t, runtimeDegraded, entry.state) | |
| 33 | + | require.Equal(t, ports.SessionBroken, entry.state) | |
| 35 | 34 | require.Equal(t, persisted, entry.record) | |
| 36 | - | require.Equal(t, ports.SessionDegraded, listSessions(t, d).Sessions[0].State) | |
| 35 | + | require.Equal(t, ports.SessionBroken, listSessions(t, d).Sessions[0].State) | |
| 37 | 36 | select { | |
| 38 | 37 | case <-entry.restoreDone: | |
| 39 | 38 | default: | |
| @@ -26,14 +26,13 @@ func TestRestoreTransientLoadFailureBecomesDiscardable(t *testing.T) { | |||||
| 26 | persisted, ok, err := catalogue.Record(record.Name) | 26 | persisted, ok, err := catalogue.Record(record.Name) | ||
| 27 | require.NoError(t, err) | 27 | require.NoError(t, err) | ||
| 28 | require.True(t, ok) | 28 | require.True(t, ok) | ||
| 29 | - | require.Equal(t, domain.RecoveryDegraded, persisted.RecoveryState) | |||
| 30 | require.Equal(t, "checkpoint load failed", persisted.DegradedReason) | 29 | require.Equal(t, "checkpoint load failed", persisted.DegradedReason) | ||
| 31 | d.mu.Lock() | 30 | d.mu.Lock() | ||
| 32 | entry := d.stopped[record.Name] | 31 | entry := d.stopped[record.Name] | ||
| 33 | d.mu.Unlock() | 32 | d.mu.Unlock() | ||
| 34 | - | require.Equal(t, runtimeDegraded, entry.state) | 33 | + | require.Equal(t, ports.SessionBroken, entry.state) |
| 35 | require.Equal(t, persisted, entry.record) | 34 | require.Equal(t, persisted, entry.record) | ||
| 36 | - | require.Equal(t, ports.SessionDegraded, listSessions(t, d).Sessions[0].State) | 35 | + | require.Equal(t, ports.SessionBroken, listSessions(t, d).Sessions[0].State) |
| 37 | select { | 36 | select { | ||
| 38 | case <-entry.restoreDone: | 37 | case <-entry.restoreDone: | ||
| 39 | default: | 38 | default: | ||
M internal/usecase/daemon/control.go +1 -1
| @@ -473,7 +473,7 @@ func (e controlExec) SessionRecovery(action string) (string, error) { | |||
| 473 | 473 | return "", err | |
| 474 | 474 | } | |
| 475 | 475 | if ok { | |
| 476 | - | e.d.setStoppedRecovery(record, runtimeFresh) | |
| 476 | + | e.d.setStoppedRecovery(record, ports.SessionStopped) | |
| 477 | 477 | } | |
| 478 | 478 | return "", nil | |
| 479 | 479 | } | |
| @@ -473,7 +473,7 @@ func (e controlExec) SessionRecovery(action string) (string, error) { | |||||
| 473 | return "", err | 473 | return "", err | ||
| 474 | } | 474 | } | ||
| 475 | if ok { | 475 | if ok { | ||
| 476 | - | e.d.setStoppedRecovery(record, runtimeFresh) | 476 | + | e.d.setStoppedRecovery(record, ports.SessionStopped) |
| 477 | } | 477 | } | ||
| 478 | return "", nil | 478 | return "", nil | ||
| 479 | } | 479 | } | ||
M internal/usecase/daemon/daemon.go +45 -77
| @@ -221,16 +221,6 @@ type parkedAttachment struct { | |||
| 221 | 221 | ||
| 222 | 222 | // session is a single multiplexed session. It owns one or more full-screen | |
| 223 | 223 | ||
| 224 | - | type runtimeRecoveryState uint8 | |
| 225 | - | ||
| 226 | - | const ( | |
| 227 | - | runtimeFresh runtimeRecoveryState = iota + 1 | |
| 228 | - | runtimeRestoring | |
| 229 | - | runtimeHealthy | |
| 230 | - | runtimeDegraded | |
| 231 | - | runtimeDeleting | |
| 232 | - | ) | |
| 233 | - | ||
| 234 | 224 | type stoppedSession struct { | |
| 235 | 225 | name string | |
| 236 | 226 | cwd string | |
| @@ -240,7 +230,7 @@ type stoppedSession struct { | |||
| 240 | 230 | tabNames []string | |
| 241 | 231 | purging bool | |
| 242 | 232 | record domain.CatalogueRecord | |
| 243 | - | state runtimeRecoveryState | |
| 233 | + | state ports.SessionState | |
| 244 | 234 | restoreDone chan struct{} | |
| 245 | 235 | } | |
| 246 | 236 | ||
| @@ -487,7 +477,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 487 | 477 | stopped := stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, incarnation: r.IncarnationID, lastUsedSeq: r.LastUsedSeq, tabNames: append([]string(nil), r.TabNames...)} | |
| 488 | 478 | if d.catalogueRecordsProvided { | |
| 489 | 479 | stopped.record = r | |
| 490 | - | stopped.state, stopped.restoreDone = initialRuntimeRecoveryState(r) | |
| 480 | + | stopped.state, stopped.restoreDone = initialSessionState(r) | |
| 491 | 481 | } | |
| 492 | 482 | d.stopped[r.Name] = stopped | |
| 493 | 483 | if !hasCreatedAt || r.CreatedAt > maxCreatedAt { | |
| @@ -811,17 +801,10 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||
| 811 | 801 | continue | |
| 812 | 802 | } | |
| 813 | 803 | state := ports.SessionStopped | |
| 814 | - | if stopped.purging { | |
| 804 | + | if stopped.purging || stopped.state == ports.SessionBroken { | |
| 815 | 805 | // Purge is the dominant externally visible state: restoration must | |
| 816 | 806 | // never make a deletion-reserved record appear attachable. | |
| 817 | - | state = ports.SessionDegraded | |
| 818 | - | } else { | |
| 819 | - | switch stopped.state { | |
| 820 | - | case runtimeRestoring: | |
| 821 | - | state = ports.SessionRestoring | |
| 822 | - | case runtimeDegraded, runtimeDeleting: | |
| 823 | - | state = ports.SessionDegraded | |
| 824 | - | } | |
| 807 | + | state = ports.SessionBroken | |
| 825 | 808 | } | |
| 826 | 809 | infos = append(infos, ports.SessionInfo{Name: name, State: state}) | |
| 827 | 810 | } | |
| @@ -959,63 +942,44 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||
| 959 | 942 | return ac | |
| 960 | 943 | } | |
| 961 | 944 | ||
| 962 | - | func (d *Daemon) waitForTargetRestore(name string) error { | |
| 963 | - | var observedClosed chan struct{} | |
| 964 | - | for { | |
| 965 | - | d.mu.Lock() | |
| 966 | - | if d.findByNameLocked(name) != nil { | |
| 967 | - | d.mu.Unlock() | |
| 968 | - | return nil | |
| 969 | - | } | |
| 970 | - | stopped, ok := d.stopped[name] | |
| 971 | - | if !ok || stopped.purging { | |
| 972 | - | d.mu.Unlock() | |
| 973 | - | return nil | |
| 974 | - | } | |
| 975 | - | switch stopped.state { | |
| 976 | - | case runtimeFresh: | |
| 977 | - | if stopped.record.Name == "" || stopped.record.RecoveryState == domain.RecoveryFresh { | |
| 978 | - | d.mu.Unlock() | |
| 979 | - | return nil | |
| 980 | - | } | |
| 981 | - | d.mu.Unlock() | |
| 982 | - | return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name} | |
| 983 | - | case runtimeRestoring: | |
| 984 | - | done := stopped.restoreDone | |
| 985 | - | if done == nil || done == observedClosed { | |
| 986 | - | d.mu.Unlock() | |
| 987 | - | return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name} | |
| 988 | - | } | |
| 989 | - | var shutdown <-chan struct{} | |
| 990 | - | if d.serveCtx != nil { | |
| 991 | - | shutdown = d.serveCtx.Done() | |
| 992 | - | } | |
| 993 | - | d.mu.Unlock() | |
| 994 | - | select { | |
| 995 | - | case <-done: | |
| 996 | - | observedClosed = done | |
| 997 | - | continue | |
| 998 | - | case <-shutdown: | |
| 999 | - | return &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | |
| 1000 | - | } | |
| 1001 | - | case runtimeHealthy: | |
| 1002 | - | // The catalogue record is healthy; only its runtime registration was | |
| 1003 | - | // skipped (shutdown, or a live session claimed the name first). Saying | |
| 1004 | - | // "degraded" here would send the operator to durable recovery for a | |
| 1005 | - | // session whose durable state is intact. | |
| 1006 | - | d.mu.Unlock() | |
| 1007 | - | return &protoErr{ports.ErrSessionDegraded, "session was not restored into this daemon: " + name} | |
| 1008 | - | case runtimeDegraded, runtimeDeleting: | |
| 1009 | - | d.mu.Unlock() | |
| 1010 | - | return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name} | |
| 1011 | - | default: | |
| 1012 | - | d.mu.Unlock() | |
| 1013 | - | if stopped.record.Name != "" { | |
| 1014 | - | return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name} | |
| 1015 | - | } | |
| 1016 | - | return nil | |
| 945 | + | func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error { | |
| 946 | + | d.mu.Lock() | |
| 947 | + | if d.findByNameLocked(name) != nil { | |
| 948 | + | d.mu.Unlock() | |
| 949 | + | return nil | |
| 950 | + | } | |
| 951 | + | stopped, ok := d.stopped[name] | |
| 952 | + | if !ok || stopped.purging { | |
| 953 | + | d.mu.Unlock() | |
| 954 | + | return nil | |
| 955 | + | } | |
| 956 | + | done := stopped.restoreDone | |
| 957 | + | d.mu.Unlock() | |
| 958 | + | ||
| 959 | + | if done != nil { | |
| 960 | + | select { | |
| 961 | + | case <-done: | |
| 962 | + | case <-ctx.Done(): | |
| 963 | + | return ctx.Err() | |
| 1017 | 964 | } | |
| 1018 | 965 | } | |
| 966 | + | ||
| 967 | + | d.mu.Lock() | |
| 968 | + | defer d.mu.Unlock() | |
| 969 | + | if d.findByNameLocked(name) != nil { | |
| 970 | + | return nil | |
| 971 | + | } | |
| 972 | + | stopped, ok = d.stopped[name] | |
| 973 | + | if !ok || stopped.record.Name == "" { | |
| 974 | + | return nil | |
| 975 | + | } | |
| 976 | + | if stopped.state == ports.SessionBroken { | |
| 977 | + | return &protoErr{ports.ErrInternal, "session durable state is broken: " + name} | |
| 978 | + | } | |
| 979 | + | if stopped.record.Committed == nil { | |
| 980 | + | return nil | |
| 981 | + | } | |
| 982 | + | return &protoErr{ports.ErrInternal, "session was not restored into this daemon: " + name} | |
| 1019 | 983 | } | |
| 1020 | 984 | ||
| 1021 | 985 | // route resolves a Hello to a session and a freshly attached client, creating | |
| @@ -1033,7 +997,11 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 1033 | 997 | } | |
| 1034 | 998 | } | |
| 1035 | 999 | if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach { | |
| 1036 | - | if err := d.waitForTargetRestore(h.Name); err != nil { | |
| 1000 | + | ctx := d.serveCtx | |
| 1001 | + | if ctx == nil { | |
| 1002 | + | ctx = context.Background() | |
| 1003 | + | } | |
| 1004 | + | if err := d.waitForTargetRestore(ctx, h.Name); err != nil { | |
| 1037 | 1005 | return nil, nil, err | |
| 1038 | 1006 | } | |
| 1039 | 1007 | } | |
| @@ -221,16 +221,6 @@ type parkedAttachment struct { | |||||
| 221 | 221 | ||||
| 222 | // session is a single multiplexed session. It owns one or more full-screen | 222 | // session is a single multiplexed session. It owns one or more full-screen | ||
| 223 | 223 | ||||
| 224 | - | type runtimeRecoveryState uint8 | |||
| 225 | - | ||||
| 226 | - | const ( | |||
| 227 | - | runtimeFresh runtimeRecoveryState = iota + 1 | |||
| 228 | - | runtimeRestoring | |||
| 229 | - | runtimeHealthy | |||
| 230 | - | runtimeDegraded | |||
| 231 | - | runtimeDeleting | |||
| 232 | - | ) | |||
| 233 | - | ||||
| 234 | type stoppedSession struct { | 224 | type stoppedSession struct { | ||
| 235 | name string | 225 | name string | ||
| 236 | cwd string | 226 | cwd string | ||
| @@ -240,7 +230,7 @@ type stoppedSession struct { | |||||
| 240 | tabNames []string | 230 | tabNames []string | ||
| 241 | purging bool | 231 | purging bool | ||
| 242 | record domain.CatalogueRecord | 232 | record domain.CatalogueRecord | ||
| 243 | - | state runtimeRecoveryState | 233 | + | state ports.SessionState |
| 244 | restoreDone chan struct{} | 234 | restoreDone chan struct{} | ||
| 245 | } | 235 | } | ||
| 246 | 236 | ||||
| @@ -487,7 +477,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 487 | stopped := stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, incarnation: r.IncarnationID, lastUsedSeq: r.LastUsedSeq, tabNames: append([]string(nil), r.TabNames...)} | 477 | stopped := stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, incarnation: r.IncarnationID, lastUsedSeq: r.LastUsedSeq, tabNames: append([]string(nil), r.TabNames...)} | ||
| 488 | if d.catalogueRecordsProvided { | 478 | if d.catalogueRecordsProvided { | ||
| 489 | stopped.record = r | 479 | stopped.record = r | ||
| 490 | - | stopped.state, stopped.restoreDone = initialRuntimeRecoveryState(r) | 480 | + | stopped.state, stopped.restoreDone = initialSessionState(r) |
| 491 | } | 481 | } | ||
| 492 | d.stopped[r.Name] = stopped | 482 | d.stopped[r.Name] = stopped | ||
| 493 | if !hasCreatedAt || r.CreatedAt > maxCreatedAt { | 483 | if !hasCreatedAt || r.CreatedAt > maxCreatedAt { | ||
| @@ -811,17 +801,10 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||||
| 811 | continue | 801 | continue | ||
| 812 | } | 802 | } | ||
| 813 | state := ports.SessionStopped | 803 | state := ports.SessionStopped | ||
| 814 | - | if stopped.purging { | 804 | + | if stopped.purging || stopped.state == ports.SessionBroken { |
| 815 | // Purge is the dominant externally visible state: restoration must | 805 | // Purge is the dominant externally visible state: restoration must | ||
| 816 | // never make a deletion-reserved record appear attachable. | 806 | // never make a deletion-reserved record appear attachable. | ||
| 817 | - | state = ports.SessionDegraded | 807 | + | state = ports.SessionBroken |
| 818 | - | } else { | |||
| 819 | - | switch stopped.state { | |||
| 820 | - | case runtimeRestoring: | |||
| 821 | - | state = ports.SessionRestoring | |||
| 822 | - | case runtimeDegraded, runtimeDeleting: | |||
| 823 | - | state = ports.SessionDegraded | |||
| 824 | - | } | |||
| 825 | } | 808 | } | ||
| 826 | infos = append(infos, ports.SessionInfo{Name: name, State: state}) | 809 | infos = append(infos, ports.SessionInfo{Name: name, State: state}) | ||
| 827 | } | 810 | } | ||
| @@ -959,63 +942,44 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||||
| 959 | return ac | 942 | return ac | ||
| 960 | } | 943 | } | ||
| 961 | 944 | ||||
| 962 | - | func (d *Daemon) waitForTargetRestore(name string) error { | 945 | + | func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error { |
| 963 | - | var observedClosed chan struct{} | 946 | + | d.mu.Lock() |
| 964 | - | for { | 947 | + | if d.findByNameLocked(name) != nil { |
| 965 | - | d.mu.Lock() | 948 | + | d.mu.Unlock() |
| 966 | - | if d.findByNameLocked(name) != nil { | 949 | + | return nil |
| 967 | - | d.mu.Unlock() | 950 | + | } |
| 968 | - | return nil | 951 | + | stopped, ok := d.stopped[name] |
| 969 | - | } | 952 | + | if !ok || stopped.purging { |
| 970 | - | stopped, ok := d.stopped[name] | 953 | + | d.mu.Unlock() |
| 971 | - | if !ok || stopped.purging { | 954 | + | return nil |
| 972 | - | d.mu.Unlock() | 955 | + | } |
| 973 | - | return nil | 956 | + | done := stopped.restoreDone |
| 974 | - | } | 957 | + | d.mu.Unlock() |
| 975 | - | switch stopped.state { | 958 | + | |
| 976 | - | case runtimeFresh: | 959 | + | if done != nil { |
| 977 | - | if stopped.record.Name == "" || stopped.record.RecoveryState == domain.RecoveryFresh { | 960 | + | select { |
| 978 | - | d.mu.Unlock() | 961 | + | case <-done: |
| 979 | - | return nil | 962 | + | case <-ctx.Done(): |
| 980 | - | } | 963 | + | return ctx.Err() |
| 981 | - | d.mu.Unlock() | |||
| 982 | - | return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name} | |||
| 983 | - | case runtimeRestoring: | |||
| 984 | - | done := stopped.restoreDone | |||
| 985 | - | if done == nil || done == observedClosed { | |||
| 986 | - | d.mu.Unlock() | |||
| 987 | - | return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name} | |||
| 988 | - | } | |||
| 989 | - | var shutdown <-chan struct{} | |||
| 990 | - | if d.serveCtx != nil { | |||
| 991 | - | shutdown = d.serveCtx.Done() | |||
| 992 | - | } | |||
| 993 | - | d.mu.Unlock() | |||
| 994 | - | select { | |||
| 995 | - | case <-done: | |||
| 996 | - | observedClosed = done | |||
| 997 | - | continue | |||
| 998 | - | case <-shutdown: | |||
| 999 | - | return &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | |||
| 1000 | - | } | |||
| 1001 | - | case runtimeHealthy: | |||
| 1002 | - | // The catalogue record is healthy; only its runtime registration was | |||
| 1003 | - | // skipped (shutdown, or a live session claimed the name first). Saying | |||
| 1004 | - | // "degraded" here would send the operator to durable recovery for a | |||
| 1005 | - | // session whose durable state is intact. | |||
| 1006 | - | d.mu.Unlock() | |||
| 1007 | - | return &protoErr{ports.ErrSessionDegraded, "session was not restored into this daemon: " + name} | |||
| 1008 | - | case runtimeDegraded, runtimeDeleting: | |||
| 1009 | - | d.mu.Unlock() | |||
| 1010 | - | return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name} | |||
| 1011 | - | default: | |||
| 1012 | - | d.mu.Unlock() | |||
| 1013 | - | if stopped.record.Name != "" { | |||
| 1014 | - | return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name} | |||
| 1015 | - | } | |||
| 1016 | - | return nil | |||
| 1017 | } | 964 | } | ||
| 1018 | } | 965 | } | ||
| 966 | + | ||||
| 967 | + | d.mu.Lock() | |||
| 968 | + | defer d.mu.Unlock() | |||
| 969 | + | if d.findByNameLocked(name) != nil { | |||
| 970 | + | return nil | |||
| 971 | + | } | |||
| 972 | + | stopped, ok = d.stopped[name] | |||
| 973 | + | if !ok || stopped.record.Name == "" { | |||
| 974 | + | return nil | |||
| 975 | + | } | |||
| 976 | + | if stopped.state == ports.SessionBroken { | |||
| 977 | + | return &protoErr{ports.ErrInternal, "session durable state is broken: " + name} | |||
| 978 | + | } | |||
| 979 | + | if stopped.record.Committed == nil { | |||
| 980 | + | return nil | |||
| 981 | + | } | |||
| 982 | + | return &protoErr{ports.ErrInternal, "session was not restored into this daemon: " + name} | |||
| 1019 | } | 983 | } | ||
| 1020 | 984 | ||||
| 1021 | // route resolves a Hello to a session and a freshly attached client, creating | 985 | // route resolves a Hello to a session and a freshly attached client, creating | ||
| @@ -1033,7 +997,11 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 1033 | } | 997 | } | ||
| 1034 | } | 998 | } | ||
| 1035 | if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach { | 999 | if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach { | ||
| 1036 | - | if err := d.waitForTargetRestore(h.Name); err != nil { | 1000 | + | ctx := d.serveCtx |
| 1001 | + | if ctx == nil { | |||
| 1002 | + | ctx = context.Background() | |||
| 1003 | + | } | |||
| 1004 | + | if err := d.waitForTargetRestore(ctx, h.Name); err != nil { | |||
| 1037 | return nil, nil, err | 1005 | return nil, nil, err | ||
| 1038 | } | 1006 | } | ||
| 1039 | } | 1007 | } | ||
M internal/usecase/daemon/durable_recovery_test.go +46 -38
| @@ -168,7 +168,6 @@ func durableRecoveryRecord(index int) domain.CatalogueRecord { | |||
| 168 | 168 | Cwd: "/tmp", | |
| 169 | 169 | CreatedAt: int64(index + 1), | |
| 170 | 170 | UpdatedAt: int64(index + 1), | |
| 171 | - | RecoveryState: domain.RecoveryHealthy, | |
| 172 | 171 | Committed: &ref, | |
| 173 | 172 | } | |
| 174 | 173 | } | |
| @@ -208,7 +207,6 @@ func TestFinalCheckpointFailurePreservesCatalogue(t *testing.T) { | |||
| 208 | 207 | IncarnationID: domain.IncarnationID{1}, | |
| 209 | 208 | Cwd: "/work", | |
| 210 | 209 | CreatedAt: 42, | |
| 211 | - | RecoveryState: domain.RecoveryHealthy, | |
| 212 | 210 | Committed: &priorRef, | |
| 213 | 211 | } | |
| 214 | 212 | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{prior}) | |
| @@ -267,8 +265,8 @@ func TestCatalogueRestoreIndependent(t *testing.T) { | |||
| 267 | 265 | ||
| 268 | 266 | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 269 | 267 | ||
| 270 | - | require.Equal(t, runtimeHealthy, d.stopped[healthy.Name].state) | |
| 271 | - | require.Equal(t, runtimeDegraded, d.stopped[broken.Name].state) | |
| 268 | + | require.Equal(t, ports.SessionStopped, d.stopped[healthy.Name].state) | |
| 269 | + | require.Equal(t, ports.SessionBroken, d.stopped[broken.Name].state) | |
| 272 | 270 | for _, name := range []string{healthy.Name, broken.Name} { | |
| 273 | 271 | select { | |
| 274 | 272 | case <-d.stopped[name].restoreDone: | |
| @@ -276,9 +274,9 @@ func TestCatalogueRestoreIndependent(t *testing.T) { | |||
| 276 | 274 | t.Fatalf("restore completion for %q was not closed", name) | |
| 277 | 275 | } | |
| 278 | 276 | } | |
| 279 | - | degraded, ok, _ := catalogue.Record(broken.Name) | |
| 277 | + | brokenRecord, ok, _ := catalogue.Record(broken.Name) | |
| 280 | 278 | require.True(t, ok) | |
| 281 | - | require.Equal(t, domain.RecoveryDegraded, degraded.RecoveryState) | |
| 279 | + | require.Equal(t, "checkpoint validation failed", brokenRecord.DegradedReason) | |
| 282 | 280 | require.Equal(t, 1, repository.loads[healthy.Name]) | |
| 283 | 281 | require.Equal(t, 1, repository.loads[broken.Name]) | |
| 284 | 282 | require.Empty(t, d.sessions, "a corrupt record must never open a replacement runtime") | |
| @@ -300,7 +298,7 @@ func TestCatalogueRestoreAggregateOver4096(t *testing.T) { | |||
| 300 | 298 | ||
| 301 | 299 | require.Len(t, d.stopped, count) | |
| 302 | 300 | for _, record := range records { | |
| 303 | - | require.Equal(t, runtimeHealthy, d.stopped[record.Name].state) | |
| 301 | + | require.Equal(t, ports.SessionStopped, d.stopped[record.Name].state) | |
| 304 | 302 | require.Equal(t, 1, repository.loads[record.Name]) | |
| 305 | 303 | } | |
| 306 | 304 | } | |
| @@ -314,7 +312,7 @@ func TestCatalogueRestoreSingleSessionOver4096(t *testing.T) { | |||
| 314 | 312 | // discovery API and therefore performs exactly one direct catalogue lookup. | |
| 315 | 313 | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 316 | 314 | ||
| 317 | - | require.Equal(t, runtimeHealthy, d.stopped[record.Name].state) | |
| 315 | + | require.Equal(t, ports.SessionStopped, d.stopped[record.Name].state) | |
| 318 | 316 | require.Equal(t, 1, repository.loads[record.Name]) | |
| 319 | 317 | } | |
| 320 | 318 | ||
| @@ -327,9 +325,9 @@ func TestCatalogueRestoreIncarnationMismatch(t *testing.T) { | |||
| 327 | 325 | ||
| 328 | 326 | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 329 | 327 | ||
| 330 | - | require.Equal(t, runtimeDegraded, d.stopped[record.Name].state) | |
| 328 | + | require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state) | |
| 331 | 329 | got, _, _ := catalogue.Record(record.Name) | |
| 332 | - | require.Equal(t, domain.RecoveryDegraded, got.RecoveryState) | |
| 330 | + | require.Equal(t, "checkpoint validation failed", got.DegradedReason) | |
| 333 | 331 | } | |
| 334 | 332 | ||
| 335 | 333 | type cancellationRecoveryRepository struct { | |
| @@ -359,7 +357,6 @@ func TestSessionRecoveryUsesIncomingCommandContext(t *testing.T) { | |||
| 359 | 357 | ctx := context.WithValue(t.Context(), key, "request") | |
| 360 | 358 | wantErr := errors.New("stop after context capture") | |
| 361 | 359 | record := durableRecoveryRecord(0) | |
| 362 | - | record.RecoveryState = domain.RecoveryDegraded | |
| 363 | 360 | record.DegradedReason = "checkpoint validation failed" | |
| 364 | 361 | catalogue := portsmocks.NewMockCatalogue(t) | |
| 365 | 362 | repository := portsmocks.NewMockSnapshotRepository(t) | |
| @@ -472,11 +469,9 @@ func TestPersistAndRegisterRestoredSessionRejectsReplacementIncarnation(t *testi | |||
| 472 | 469 | ||
| 473 | 470 | func TestSessionRecoveryCommand(t *testing.T) { | |
| 474 | 471 | degraded := durableRecoveryRecord(2) | |
| 475 | - | degraded.RecoveryState = domain.RecoveryDegraded | |
| 476 | 472 | degraded.DegradedReason = "checkpoint validation failed" | |
| 477 | 473 | fresh := degraded | |
| 478 | 474 | fresh.IncarnationID = domain.IncarnationID{9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9} | |
| 479 | - | fresh.RecoveryState = domain.RecoveryFresh | |
| 480 | 475 | fresh.Committed = nil | |
| 481 | 476 | fresh.DegradedReason = "" | |
| 482 | 477 | catalogue := portsmocks.NewMockCatalogue(t) | |
| @@ -492,25 +487,23 @@ func TestSessionRecoveryCommand(t *testing.T) { | |||
| 492 | 487 | ||
| 493 | 488 | _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard") | |
| 494 | 489 | require.NoError(t, err) | |
| 495 | - | require.Equal(t, runtimeFresh, d.stopped[degraded.Name].state) | |
| 490 | + | require.Equal(t, ports.SessionStopped, d.stopped[degraded.Name].state) | |
| 496 | 491 | require.Equal(t, fresh, d.stopped[degraded.Name].record) | |
| 497 | 492 | } | |
| 498 | 493 | ||
| 499 | 494 | func TestListShowsDegraded(t *testing.T) { | |
| 500 | 495 | fresh := durableRecoveryRecord(0) | |
| 501 | - | fresh.RecoveryState = domain.RecoveryFresh | |
| 502 | 496 | fresh.Committed = nil | |
| 503 | 497 | restoring := durableRecoveryRecord(1) | |
| 504 | 498 | degraded := durableRecoveryRecord(2) | |
| 505 | - | degraded.RecoveryState = domain.RecoveryDegraded | |
| 506 | 499 | degraded.DegradedReason = "checkpoint validation failed" | |
| 507 | 500 | d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{fresh, restoring, degraded}, &durableRecoveryRepository{}) | |
| 508 | 501 | ||
| 509 | 502 | listed := listSessions(t, d) | |
| 510 | 503 | require.Equal(t, []ports.SessionInfo{ | |
| 511 | 504 | {Name: fresh.Name, State: ports.SessionStopped}, | |
| 512 | - | {Name: restoring.Name, State: ports.SessionRestoring}, | |
| 513 | - | {Name: degraded.Name, State: ports.SessionDegraded}, | |
| 505 | + | {Name: restoring.Name, State: ports.SessionStopped}, | |
| 506 | + | {Name: degraded.Name, State: ports.SessionBroken}, | |
| 514 | 507 | }, listed.Sessions) | |
| 515 | 508 | } | |
| 516 | 509 | ||
| @@ -520,12 +513,12 @@ func TestListPurgingDominatesRestoringState(t *testing.T) { | |||
| 520 | 513 | d.mu.Lock() | |
| 521 | 514 | entry := d.stopped[record.Name] | |
| 522 | 515 | entry.purging = true | |
| 523 | - | entry.state = runtimeRestoring | |
| 516 | + | entry.state = ports.SessionStopped | |
| 524 | 517 | d.stopped[record.Name] = entry | |
| 525 | 518 | d.mu.Unlock() | |
| 526 | 519 | ||
| 527 | 520 | listed := listSessions(t, d) | |
| 528 | - | require.Equal(t, []ports.SessionInfo{{Name: record.Name, State: ports.SessionDegraded}}, listed.Sessions) | |
| 521 | + | require.Equal(t, []ports.SessionInfo{{Name: record.Name, State: ports.SessionBroken}}, listed.Sessions) | |
| 529 | 522 | } | |
| 530 | 523 | ||
| 531 | 524 | func TestAttachWaitsForRestore(t *testing.T) { | |
| @@ -548,16 +541,32 @@ func TestAttachWaitsForRestore(t *testing.T) { | |||
| 548 | 541 | } | |
| 549 | 542 | require.Zero(t, factory.calls.Load()) | |
| 550 | 543 | ||
| 551 | - | d.setStoppedRecovery(record, runtimeDegraded) | |
| 544 | + | d.setStoppedRecovery(record, ports.SessionBroken) | |
| 552 | 545 | d.mu.Lock() | |
| 553 | 546 | closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone) | |
| 554 | 547 | d.mu.Unlock() | |
| 555 | 548 | var protocolError *protoErr | |
| 556 | 549 | require.ErrorAs(t, <-result, &protocolError) | |
| 557 | - | require.Equal(t, ports.ErrSessionDegraded, protocolError.code) | |
| 550 | + | require.Equal(t, ports.ErrInternal, protocolError.code) | |
| 558 | 551 | require.Zero(t, factory.calls.Load()) | |
| 559 | 552 | } | |
| 560 | 553 | ||
| 554 | + | func TestAttachRestoreWaitHonorsContextCancellation(t *testing.T) { | |
| 555 | + | record := durableRecoveryRecord(0) | |
| 556 | + | d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | |
| 557 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 558 | + | result := make(chan error, 1) | |
| 559 | + | go func() { result <- d.waitForTargetRestore(ctx, record.Name) }() | |
| 560 | + | ||
| 561 | + | select { | |
| 562 | + | case err := <-result: | |
| 563 | + | t.Fatalf("restore wait returned before cancellation: %v", err) | |
| 564 | + | case <-time.After(25 * time.Millisecond): | |
| 565 | + | } | |
| 566 | + | cancel() | |
| 567 | + | require.ErrorIs(t, <-result, context.Canceled) | |
| 568 | + | } | |
| 569 | + | ||
| 561 | 570 | func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) { | |
| 562 | 571 | record := durableRecoveryRecord(0) | |
| 563 | 572 | repository := &cancellationRecoveryRepository{started: make(chan struct{})} | |
| @@ -579,11 +588,10 @@ func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) { | |||
| 579 | 588 | ||
| 580 | 589 | var protocolError *protoErr | |
| 581 | 590 | require.ErrorAs(t, <-attached, &protocolError) | |
| 582 | - | require.Equal(t, ports.ErrSessionDegraded, protocolError.code) | |
| 591 | + | require.Equal(t, ports.ErrInternal, protocolError.code) | |
| 583 | 592 | <-restored | |
| 584 | 593 | entry := d.stopped[record.Name] | |
| 585 | - | require.Equal(t, runtimeDegraded, entry.state) | |
| 586 | - | require.Equal(t, domain.RecoveryDegraded, entry.record.RecoveryState) | |
| 594 | + | require.Equal(t, ports.SessionBroken, entry.state) | |
| 587 | 595 | require.Equal(t, "restore interrupted", entry.record.DegradedReason) | |
| 588 | 596 | select { | |
| 589 | 597 | case <-entry.restoreDone: | |
| @@ -602,13 +610,13 @@ func TestWaitForTargetRestoreRejectsClosedRestoringChannel(t *testing.T) { | |||
| 602 | 610 | d.mu.Unlock() | |
| 603 | 611 | ||
| 604 | 612 | result := make(chan error, 1) | |
| 605 | - | go func() { result <- d.waitForTargetRestore(record.Name) }() | |
| 613 | + | go func() { result <- d.waitForTargetRestore(context.Background(), record.Name) }() | |
| 606 | 614 | ||
| 607 | 615 | select { | |
| 608 | 616 | case err := <-result: | |
| 609 | 617 | var protocolError *protoErr | |
| 610 | 618 | require.ErrorAs(t, err, &protocolError) | |
| 611 | - | require.Equal(t, ports.ErrSessionDegraded, protocolError.code) | |
| 619 | + | require.Equal(t, ports.ErrInternal, protocolError.code) | |
| 612 | 620 | case <-time.After(time.Second): | |
| 613 | 621 | t.Fatal("waiter spun on a closed restore channel") | |
| 614 | 622 | } | |
| @@ -620,21 +628,23 @@ func TestWaitForTargetRestoreDistinguishesHealthyRuntime(t *testing.T) { | |||
| 620 | 628 | record := durableRecoveryRecord(0) | |
| 621 | 629 | tests := []struct { | |
| 622 | 630 | name string | |
| 623 | - | state runtimeRecoveryState | |
| 631 | + | state ports.SessionState | |
| 624 | 632 | message string | |
| 625 | 633 | }{ | |
| 626 | - | {name: "healthy record without runtime", state: runtimeHealthy, message: "session was not restored into this daemon: " + record.Name}, | |
| 627 | - | {name: "degraded durable state", state: runtimeDegraded, message: "session durable state is degraded: " + record.Name}, | |
| 628 | - | {name: "deleting durable state", state: runtimeDeleting, message: "session durable state is degraded: " + record.Name}, | |
| 634 | + | {name: "healthy record without runtime", state: ports.SessionStopped, message: "session was not restored into this daemon: " + record.Name}, | |
| 635 | + | {name: "broken durable state", state: ports.SessionBroken, message: "session durable state is broken: " + record.Name}, | |
| 629 | 636 | } | |
| 630 | 637 | for _, tt := range tests { | |
| 631 | 638 | t.Run(tt.name, func(t *testing.T) { | |
| 632 | 639 | d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | |
| 633 | 640 | d.setStoppedRecovery(record, tt.state) | |
| 641 | + | d.mu.Lock() | |
| 642 | + | closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone) | |
| 643 | + | d.mu.Unlock() | |
| 634 | 644 | ||
| 635 | 645 | var protocolError *protoErr | |
| 636 | - | require.ErrorAs(t, d.waitForTargetRestore(record.Name), &protocolError) | |
| 637 | - | require.Equal(t, ports.ErrSessionDegraded, protocolError.code) | |
| 646 | + | require.ErrorAs(t, d.waitForTargetRestore(context.Background(), record.Name), &protocolError) | |
| 647 | + | require.Equal(t, ports.ErrInternal, protocolError.code) | |
| 638 | 648 | require.Equal(t, tt.message, protocolError.Error()) | |
| 639 | 649 | }) | |
| 640 | 650 | } | |
| @@ -642,7 +652,6 @@ func TestWaitForTargetRestoreDistinguishesHealthyRuntime(t *testing.T) { | |||
| 642 | 652 | ||
| 643 | 653 | func TestAttachRejectsDegraded(t *testing.T) { | |
| 644 | 654 | record := durableRecoveryRecord(0) | |
| 645 | - | record.RecoveryState = domain.RecoveryDegraded | |
| 646 | 655 | record.DegradedReason = "checkpoint validation failed" | |
| 647 | 656 | factory := &recoveryCountingPTYFactory{} | |
| 648 | 657 | d := New(factory, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}), []domain.CatalogueRecord{record})) | |
| @@ -652,7 +661,7 @@ func TestAttachRejectsDegraded(t *testing.T) { | |||
| 652 | 661 | _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil) | |
| 653 | 662 | var protocolError *protoErr | |
| 654 | 663 | require.ErrorAs(t, err, &protocolError) | |
| 655 | - | require.Equal(t, ports.ErrSessionDegraded, protocolError.code) | |
| 664 | + | require.Equal(t, ports.ErrInternal, protocolError.code) | |
| 656 | 665 | require.Zero(t, factory.calls.Load()) | |
| 657 | 666 | } | |
| 658 | 667 | ||
| @@ -664,7 +673,7 @@ func TestRestoreUnavailableReleasesPerRecordWaiters(t *testing.T) { | |||
| 664 | 673 | d.mu.Lock() | |
| 665 | 674 | entry := d.stopped[record.Name] | |
| 666 | 675 | d.mu.Unlock() | |
| 667 | - | require.Equal(t, runtimeDegraded, entry.state) | |
| 676 | + | require.Equal(t, ports.SessionBroken, entry.state) | |
| 668 | 677 | select { | |
| 669 | 678 | case <-entry.restoreDone: | |
| 670 | 679 | default: | |
| @@ -674,13 +683,12 @@ func TestRestoreUnavailableReleasesPerRecordWaiters(t *testing.T) { | |||
| 674 | 683 | ||
| 675 | 684 | func TestCatalogueRestoreDegradedVisible(t *testing.T) { | |
| 676 | 685 | record := durableRecoveryRecord(0) | |
| 677 | - | record.RecoveryState = domain.RecoveryDegraded | |
| 678 | 686 | record.DegradedReason = "checkpoint validation failed" | |
| 679 | 687 | repository := &durableRecoveryRepository{generations: make(map[string]ports.SnapshotGeneration), errors: make(map[string]error), loads: make(map[string]int), repairs: make(map[string]domain.CheckpointRef)} | |
| 680 | 688 | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | |
| 681 | 689 | ||
| 682 | 690 | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | |
| 683 | 691 | ||
| 684 | - | require.Equal(t, runtimeDegraded, d.stopped[record.Name].state) | |
| 692 | + | require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state) | |
| 685 | 693 | require.Zero(t, repository.loads[record.Name]) | |
| 686 | 694 | } | |
| @@ -168,7 +168,6 @@ func durableRecoveryRecord(index int) domain.CatalogueRecord { | |||||
| 168 | Cwd: "/tmp", | 168 | Cwd: "/tmp", | ||
| 169 | CreatedAt: int64(index + 1), | 169 | CreatedAt: int64(index + 1), | ||
| 170 | UpdatedAt: int64(index + 1), | 170 | UpdatedAt: int64(index + 1), | ||
| 171 | - | RecoveryState: domain.RecoveryHealthy, | |||
| 172 | Committed: &ref, | 171 | Committed: &ref, | ||
| 173 | } | 172 | } | ||
| 174 | } | 173 | } | ||
| @@ -208,7 +207,6 @@ func TestFinalCheckpointFailurePreservesCatalogue(t *testing.T) { | |||||
| 208 | IncarnationID: domain.IncarnationID{1}, | 207 | IncarnationID: domain.IncarnationID{1}, | ||
| 209 | Cwd: "/work", | 208 | Cwd: "/work", | ||
| 210 | CreatedAt: 42, | 209 | CreatedAt: 42, | ||
| 211 | - | RecoveryState: domain.RecoveryHealthy, | |||
| 212 | Committed: &priorRef, | 210 | Committed: &priorRef, | ||
| 213 | } | 211 | } | ||
| 214 | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{prior}) | 212 | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{prior}) | ||
| @@ -267,8 +265,8 @@ func TestCatalogueRestoreIndependent(t *testing.T) { | |||||
| 267 | 265 | ||||
| 268 | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | 266 | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | ||
| 269 | 267 | ||||
| 270 | - | require.Equal(t, runtimeHealthy, d.stopped[healthy.Name].state) | 268 | + | require.Equal(t, ports.SessionStopped, d.stopped[healthy.Name].state) |
| 271 | - | require.Equal(t, runtimeDegraded, d.stopped[broken.Name].state) | 269 | + | require.Equal(t, ports.SessionBroken, d.stopped[broken.Name].state) |
| 272 | for _, name := range []string{healthy.Name, broken.Name} { | 270 | for _, name := range []string{healthy.Name, broken.Name} { | ||
| 273 | select { | 271 | select { | ||
| 274 | case <-d.stopped[name].restoreDone: | 272 | case <-d.stopped[name].restoreDone: | ||
| @@ -276,9 +274,9 @@ func TestCatalogueRestoreIndependent(t *testing.T) { | |||||
| 276 | t.Fatalf("restore completion for %q was not closed", name) | 274 | t.Fatalf("restore completion for %q was not closed", name) | ||
| 277 | } | 275 | } | ||
| 278 | } | 276 | } | ||
| 279 | - | degraded, ok, _ := catalogue.Record(broken.Name) | 277 | + | brokenRecord, ok, _ := catalogue.Record(broken.Name) |
| 280 | require.True(t, ok) | 278 | require.True(t, ok) | ||
| 281 | - | require.Equal(t, domain.RecoveryDegraded, degraded.RecoveryState) | 279 | + | require.Equal(t, "checkpoint validation failed", brokenRecord.DegradedReason) |
| 282 | require.Equal(t, 1, repository.loads[healthy.Name]) | 280 | require.Equal(t, 1, repository.loads[healthy.Name]) | ||
| 283 | require.Equal(t, 1, repository.loads[broken.Name]) | 281 | require.Equal(t, 1, repository.loads[broken.Name]) | ||
| 284 | require.Empty(t, d.sessions, "a corrupt record must never open a replacement runtime") | 282 | require.Empty(t, d.sessions, "a corrupt record must never open a replacement runtime") | ||
| @@ -300,7 +298,7 @@ func TestCatalogueRestoreAggregateOver4096(t *testing.T) { | |||||
| 300 | 298 | ||||
| 301 | require.Len(t, d.stopped, count) | 299 | require.Len(t, d.stopped, count) | ||
| 302 | for _, record := range records { | 300 | for _, record := range records { | ||
| 303 | - | require.Equal(t, runtimeHealthy, d.stopped[record.Name].state) | 301 | + | require.Equal(t, ports.SessionStopped, d.stopped[record.Name].state) |
| 304 | require.Equal(t, 1, repository.loads[record.Name]) | 302 | require.Equal(t, 1, repository.loads[record.Name]) | ||
| 305 | } | 303 | } | ||
| 306 | } | 304 | } | ||
| @@ -314,7 +312,7 @@ func TestCatalogueRestoreSingleSessionOver4096(t *testing.T) { | |||||
| 314 | // discovery API and therefore performs exactly one direct catalogue lookup. | 312 | // discovery API and therefore performs exactly one direct catalogue lookup. | ||
| 315 | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | 313 | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | ||
| 316 | 314 | ||||
| 317 | - | require.Equal(t, runtimeHealthy, d.stopped[record.Name].state) | 315 | + | require.Equal(t, ports.SessionStopped, d.stopped[record.Name].state) |
| 318 | require.Equal(t, 1, repository.loads[record.Name]) | 316 | require.Equal(t, 1, repository.loads[record.Name]) | ||
| 319 | } | 317 | } | ||
| 320 | 318 | ||||
| @@ -327,9 +325,9 @@ func TestCatalogueRestoreIncarnationMismatch(t *testing.T) { | |||||
| 327 | 325 | ||||
| 328 | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | 326 | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | ||
| 329 | 327 | ||||
| 330 | - | require.Equal(t, runtimeDegraded, d.stopped[record.Name].state) | 328 | + | require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state) |
| 331 | got, _, _ := catalogue.Record(record.Name) | 329 | got, _, _ := catalogue.Record(record.Name) | ||
| 332 | - | require.Equal(t, domain.RecoveryDegraded, got.RecoveryState) | 330 | + | require.Equal(t, "checkpoint validation failed", got.DegradedReason) |
| 333 | } | 331 | } | ||
| 334 | 332 | ||||
| 335 | type cancellationRecoveryRepository struct { | 333 | type cancellationRecoveryRepository struct { | ||
| @@ -359,7 +357,6 @@ func TestSessionRecoveryUsesIncomingCommandContext(t *testing.T) { | |||||
| 359 | ctx := context.WithValue(t.Context(), key, "request") | 357 | ctx := context.WithValue(t.Context(), key, "request") | ||
| 360 | wantErr := errors.New("stop after context capture") | 358 | wantErr := errors.New("stop after context capture") | ||
| 361 | record := durableRecoveryRecord(0) | 359 | record := durableRecoveryRecord(0) | ||
| 362 | - | record.RecoveryState = domain.RecoveryDegraded | |||
| 363 | record.DegradedReason = "checkpoint validation failed" | 360 | record.DegradedReason = "checkpoint validation failed" | ||
| 364 | catalogue := portsmocks.NewMockCatalogue(t) | 361 | catalogue := portsmocks.NewMockCatalogue(t) | ||
| 365 | repository := portsmocks.NewMockSnapshotRepository(t) | 362 | repository := portsmocks.NewMockSnapshotRepository(t) | ||
| @@ -472,11 +469,9 @@ func TestPersistAndRegisterRestoredSessionRejectsReplacementIncarnation(t *testi | |||||
| 472 | 469 | ||||
| 473 | func TestSessionRecoveryCommand(t *testing.T) { | 470 | func TestSessionRecoveryCommand(t *testing.T) { | ||
| 474 | degraded := durableRecoveryRecord(2) | 471 | degraded := durableRecoveryRecord(2) | ||
| 475 | - | degraded.RecoveryState = domain.RecoveryDegraded | |||
| 476 | degraded.DegradedReason = "checkpoint validation failed" | 472 | degraded.DegradedReason = "checkpoint validation failed" | ||
| 477 | fresh := degraded | 473 | fresh := degraded | ||
| 478 | fresh.IncarnationID = domain.IncarnationID{9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9} | 474 | fresh.IncarnationID = domain.IncarnationID{9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9} | ||
| 479 | - | fresh.RecoveryState = domain.RecoveryFresh | |||
| 480 | fresh.Committed = nil | 475 | fresh.Committed = nil | ||
| 481 | fresh.DegradedReason = "" | 476 | fresh.DegradedReason = "" | ||
| 482 | catalogue := portsmocks.NewMockCatalogue(t) | 477 | catalogue := portsmocks.NewMockCatalogue(t) | ||
| @@ -492,25 +487,23 @@ func TestSessionRecoveryCommand(t *testing.T) { | |||||
| 492 | 487 | ||||
| 493 | _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard") | 488 | _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard") | ||
| 494 | require.NoError(t, err) | 489 | require.NoError(t, err) | ||
| 495 | - | require.Equal(t, runtimeFresh, d.stopped[degraded.Name].state) | 490 | + | require.Equal(t, ports.SessionStopped, d.stopped[degraded.Name].state) |
| 496 | require.Equal(t, fresh, d.stopped[degraded.Name].record) | 491 | require.Equal(t, fresh, d.stopped[degraded.Name].record) | ||
| 497 | } | 492 | } | ||
| 498 | 493 | ||||
| 499 | func TestListShowsDegraded(t *testing.T) { | 494 | func TestListShowsDegraded(t *testing.T) { | ||
| 500 | fresh := durableRecoveryRecord(0) | 495 | fresh := durableRecoveryRecord(0) | ||
| 501 | - | fresh.RecoveryState = domain.RecoveryFresh | |||
| 502 | fresh.Committed = nil | 496 | fresh.Committed = nil | ||
| 503 | restoring := durableRecoveryRecord(1) | 497 | restoring := durableRecoveryRecord(1) | ||
| 504 | degraded := durableRecoveryRecord(2) | 498 | degraded := durableRecoveryRecord(2) | ||
| 505 | - | degraded.RecoveryState = domain.RecoveryDegraded | |||
| 506 | degraded.DegradedReason = "checkpoint validation failed" | 499 | degraded.DegradedReason = "checkpoint validation failed" | ||
| 507 | d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{fresh, restoring, degraded}, &durableRecoveryRepository{}) | 500 | d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{fresh, restoring, degraded}, &durableRecoveryRepository{}) | ||
| 508 | 501 | ||||
| 509 | listed := listSessions(t, d) | 502 | listed := listSessions(t, d) | ||
| 510 | require.Equal(t, []ports.SessionInfo{ | 503 | require.Equal(t, []ports.SessionInfo{ | ||
| 511 | {Name: fresh.Name, State: ports.SessionStopped}, | 504 | {Name: fresh.Name, State: ports.SessionStopped}, | ||
| 512 | - | {Name: restoring.Name, State: ports.SessionRestoring}, | 505 | + | {Name: restoring.Name, State: ports.SessionStopped}, |
| 513 | - | {Name: degraded.Name, State: ports.SessionDegraded}, | 506 | + | {Name: degraded.Name, State: ports.SessionBroken}, |
| 514 | }, listed.Sessions) | 507 | }, listed.Sessions) | ||
| 515 | } | 508 | } | ||
| 516 | 509 | ||||
| @@ -520,12 +513,12 @@ func TestListPurgingDominatesRestoringState(t *testing.T) { | |||||
| 520 | d.mu.Lock() | 513 | d.mu.Lock() | ||
| 521 | entry := d.stopped[record.Name] | 514 | entry := d.stopped[record.Name] | ||
| 522 | entry.purging = true | 515 | entry.purging = true | ||
| 523 | - | entry.state = runtimeRestoring | 516 | + | entry.state = ports.SessionStopped |
| 524 | d.stopped[record.Name] = entry | 517 | d.stopped[record.Name] = entry | ||
| 525 | d.mu.Unlock() | 518 | d.mu.Unlock() | ||
| 526 | 519 | ||||
| 527 | listed := listSessions(t, d) | 520 | listed := listSessions(t, d) | ||
| 528 | - | require.Equal(t, []ports.SessionInfo{{Name: record.Name, State: ports.SessionDegraded}}, listed.Sessions) | 521 | + | require.Equal(t, []ports.SessionInfo{{Name: record.Name, State: ports.SessionBroken}}, listed.Sessions) |
| 529 | } | 522 | } | ||
| 530 | 523 | ||||
| 531 | func TestAttachWaitsForRestore(t *testing.T) { | 524 | func TestAttachWaitsForRestore(t *testing.T) { | ||
| @@ -548,16 +541,32 @@ func TestAttachWaitsForRestore(t *testing.T) { | |||||
| 548 | } | 541 | } | ||
| 549 | require.Zero(t, factory.calls.Load()) | 542 | require.Zero(t, factory.calls.Load()) | ||
| 550 | 543 | ||||
| 551 | - | d.setStoppedRecovery(record, runtimeDegraded) | 544 | + | d.setStoppedRecovery(record, ports.SessionBroken) |
| 552 | d.mu.Lock() | 545 | d.mu.Lock() | ||
| 553 | closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone) | 546 | closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone) | ||
| 554 | d.mu.Unlock() | 547 | d.mu.Unlock() | ||
| 555 | var protocolError *protoErr | 548 | var protocolError *protoErr | ||
| 556 | require.ErrorAs(t, <-result, &protocolError) | 549 | require.ErrorAs(t, <-result, &protocolError) | ||
| 557 | - | require.Equal(t, ports.ErrSessionDegraded, protocolError.code) | 550 | + | require.Equal(t, ports.ErrInternal, protocolError.code) |
| 558 | require.Zero(t, factory.calls.Load()) | 551 | require.Zero(t, factory.calls.Load()) | ||
| 559 | } | 552 | } | ||
| 560 | 553 | ||||
| 554 | + | func TestAttachRestoreWaitHonorsContextCancellation(t *testing.T) { | |||
| 555 | + | record := durableRecoveryRecord(0) | |||
| 556 | + | d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | |||
| 557 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 558 | + | result := make(chan error, 1) | |||
| 559 | + | go func() { result <- d.waitForTargetRestore(ctx, record.Name) }() | |||
| 560 | + | ||||
| 561 | + | select { | |||
| 562 | + | case err := <-result: | |||
| 563 | + | t.Fatalf("restore wait returned before cancellation: %v", err) | |||
| 564 | + | case <-time.After(25 * time.Millisecond): | |||
| 565 | + | } | |||
| 566 | + | cancel() | |||
| 567 | + | require.ErrorIs(t, <-result, context.Canceled) | |||
| 568 | + | } | |||
| 569 | + | ||||
| 561 | func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) { | 570 | func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) { | ||
| 562 | record := durableRecoveryRecord(0) | 571 | record := durableRecoveryRecord(0) | ||
| 563 | repository := &cancellationRecoveryRepository{started: make(chan struct{})} | 572 | repository := &cancellationRecoveryRepository{started: make(chan struct{})} | ||
| @@ -579,11 +588,10 @@ func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) { | |||||
| 579 | 588 | ||||
| 580 | var protocolError *protoErr | 589 | var protocolError *protoErr | ||
| 581 | require.ErrorAs(t, <-attached, &protocolError) | 590 | require.ErrorAs(t, <-attached, &protocolError) | ||
| 582 | - | require.Equal(t, ports.ErrSessionDegraded, protocolError.code) | 591 | + | require.Equal(t, ports.ErrInternal, protocolError.code) |
| 583 | <-restored | 592 | <-restored | ||
| 584 | entry := d.stopped[record.Name] | 593 | entry := d.stopped[record.Name] | ||
| 585 | - | require.Equal(t, runtimeDegraded, entry.state) | 594 | + | require.Equal(t, ports.SessionBroken, entry.state) |
| 586 | - | require.Equal(t, domain.RecoveryDegraded, entry.record.RecoveryState) | |||
| 587 | require.Equal(t, "restore interrupted", entry.record.DegradedReason) | 595 | require.Equal(t, "restore interrupted", entry.record.DegradedReason) | ||
| 588 | select { | 596 | select { | ||
| 589 | case <-entry.restoreDone: | 597 | case <-entry.restoreDone: | ||
| @@ -602,13 +610,13 @@ func TestWaitForTargetRestoreRejectsClosedRestoringChannel(t *testing.T) { | |||||
| 602 | d.mu.Unlock() | 610 | d.mu.Unlock() | ||
| 603 | 611 | ||||
| 604 | result := make(chan error, 1) | 612 | result := make(chan error, 1) | ||
| 605 | - | go func() { result <- d.waitForTargetRestore(record.Name) }() | 613 | + | go func() { result <- d.waitForTargetRestore(context.Background(), record.Name) }() |
| 606 | 614 | ||||
| 607 | select { | 615 | select { | ||
| 608 | case err := <-result: | 616 | case err := <-result: | ||
| 609 | var protocolError *protoErr | 617 | var protocolError *protoErr | ||
| 610 | require.ErrorAs(t, err, &protocolError) | 618 | require.ErrorAs(t, err, &protocolError) | ||
| 611 | - | require.Equal(t, ports.ErrSessionDegraded, protocolError.code) | 619 | + | require.Equal(t, ports.ErrInternal, protocolError.code) |
| 612 | case <-time.After(time.Second): | 620 | case <-time.After(time.Second): | ||
| 613 | t.Fatal("waiter spun on a closed restore channel") | 621 | t.Fatal("waiter spun on a closed restore channel") | ||
| 614 | } | 622 | } | ||
| @@ -620,21 +628,23 @@ func TestWaitForTargetRestoreDistinguishesHealthyRuntime(t *testing.T) { | |||||
| 620 | record := durableRecoveryRecord(0) | 628 | record := durableRecoveryRecord(0) | ||
| 621 | tests := []struct { | 629 | tests := []struct { | ||
| 622 | name string | 630 | name string | ||
| 623 | - | state runtimeRecoveryState | 631 | + | state ports.SessionState |
| 624 | message string | 632 | message string | ||
| 625 | }{ | 633 | }{ | ||
| 626 | - | {name: "healthy record without runtime", state: runtimeHealthy, message: "session was not restored into this daemon: " + record.Name}, | 634 | + | {name: "healthy record without runtime", state: ports.SessionStopped, message: "session was not restored into this daemon: " + record.Name}, |
| 627 | - | {name: "degraded durable state", state: runtimeDegraded, message: "session durable state is degraded: " + record.Name}, | 635 | + | {name: "broken durable state", state: ports.SessionBroken, message: "session durable state is broken: " + record.Name}, |
| 628 | - | {name: "deleting durable state", state: runtimeDeleting, message: "session durable state is degraded: " + record.Name}, | |||
| 629 | } | 636 | } | ||
| 630 | for _, tt := range tests { | 637 | for _, tt := range tests { | ||
| 631 | t.Run(tt.name, func(t *testing.T) { | 638 | t.Run(tt.name, func(t *testing.T) { | ||
| 632 | d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | 639 | d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | ||
| 633 | d.setStoppedRecovery(record, tt.state) | 640 | d.setStoppedRecovery(record, tt.state) | ||
| 641 | + | d.mu.Lock() | |||
| 642 | + | closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone) | |||
| 643 | + | d.mu.Unlock() | |||
| 634 | 644 | ||||
| 635 | var protocolError *protoErr | 645 | var protocolError *protoErr | ||
| 636 | - | require.ErrorAs(t, d.waitForTargetRestore(record.Name), &protocolError) | 646 | + | require.ErrorAs(t, d.waitForTargetRestore(context.Background(), record.Name), &protocolError) |
| 637 | - | require.Equal(t, ports.ErrSessionDegraded, protocolError.code) | 647 | + | require.Equal(t, ports.ErrInternal, protocolError.code) |
| 638 | require.Equal(t, tt.message, protocolError.Error()) | 648 | require.Equal(t, tt.message, protocolError.Error()) | ||
| 639 | }) | 649 | }) | ||
| 640 | } | 650 | } | ||
| @@ -642,7 +652,6 @@ func TestWaitForTargetRestoreDistinguishesHealthyRuntime(t *testing.T) { | |||||
| 642 | 652 | ||||
| 643 | func TestAttachRejectsDegraded(t *testing.T) { | 653 | func TestAttachRejectsDegraded(t *testing.T) { | ||
| 644 | record := durableRecoveryRecord(0) | 654 | record := durableRecoveryRecord(0) | ||
| 645 | - | record.RecoveryState = domain.RecoveryDegraded | |||
| 646 | record.DegradedReason = "checkpoint validation failed" | 655 | record.DegradedReason = "checkpoint validation failed" | ||
| 647 | factory := &recoveryCountingPTYFactory{} | 656 | factory := &recoveryCountingPTYFactory{} | ||
| 648 | d := New(factory, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}), []domain.CatalogueRecord{record})) | 657 | d := New(factory, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}), []domain.CatalogueRecord{record})) | ||
| @@ -652,7 +661,7 @@ func TestAttachRejectsDegraded(t *testing.T) { | |||||
| 652 | _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil) | 661 | _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil) | ||
| 653 | var protocolError *protoErr | 662 | var protocolError *protoErr | ||
| 654 | require.ErrorAs(t, err, &protocolError) | 663 | require.ErrorAs(t, err, &protocolError) | ||
| 655 | - | require.Equal(t, ports.ErrSessionDegraded, protocolError.code) | 664 | + | require.Equal(t, ports.ErrInternal, protocolError.code) |
| 656 | require.Zero(t, factory.calls.Load()) | 665 | require.Zero(t, factory.calls.Load()) | ||
| 657 | } | 666 | } | ||
| 658 | 667 | ||||
| @@ -664,7 +673,7 @@ func TestRestoreUnavailableReleasesPerRecordWaiters(t *testing.T) { | |||||
| 664 | d.mu.Lock() | 673 | d.mu.Lock() | ||
| 665 | entry := d.stopped[record.Name] | 674 | entry := d.stopped[record.Name] | ||
| 666 | d.mu.Unlock() | 675 | d.mu.Unlock() | ||
| 667 | - | require.Equal(t, runtimeDegraded, entry.state) | 676 | + | require.Equal(t, ports.SessionBroken, entry.state) |
| 668 | select { | 677 | select { | ||
| 669 | case <-entry.restoreDone: | 678 | case <-entry.restoreDone: | ||
| 670 | default: | 679 | default: | ||
| @@ -674,13 +683,12 @@ func TestRestoreUnavailableReleasesPerRecordWaiters(t *testing.T) { | |||||
| 674 | 683 | ||||
| 675 | func TestCatalogueRestoreDegradedVisible(t *testing.T) { | 684 | func TestCatalogueRestoreDegradedVisible(t *testing.T) { | ||
| 676 | record := durableRecoveryRecord(0) | 685 | record := durableRecoveryRecord(0) | ||
| 677 | - | record.RecoveryState = domain.RecoveryDegraded | |||
| 678 | record.DegradedReason = "checkpoint validation failed" | 686 | record.DegradedReason = "checkpoint validation failed" | ||
| 679 | repository := &durableRecoveryRepository{generations: make(map[string]ports.SnapshotGeneration), errors: make(map[string]error), loads: make(map[string]int), repairs: make(map[string]domain.CheckpointRef)} | 687 | repository := &durableRecoveryRepository{generations: make(map[string]ports.SnapshotGeneration), errors: make(map[string]error), loads: make(map[string]int), repairs: make(map[string]domain.CheckpointRef)} | ||
| 680 | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | 688 | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository) | ||
| 681 | 689 | ||||
| 682 | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | 690 | d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue)) | ||
| 683 | 691 | ||||
| 684 | - | require.Equal(t, runtimeDegraded, d.stopped[record.Name].state) | 692 | + | require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state) |
| 685 | require.Zero(t, repository.loads[record.Name]) | 693 | require.Zero(t, repository.loads[record.Name]) | ||
| 686 | } | 694 | } | ||
M internal/usecase/daemon/picker.go +8 -3
| @@ -2,6 +2,7 @@ | |||
| 2 | 2 | package daemon | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "context" | |
| 5 | 6 | "sort" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| @@ -359,7 +360,11 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker | |||
| 359 | 360 | // as part of the source ownership transfer. | |
| 360 | 361 | func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error { | |
| 361 | 362 | if target.Name != "" { | |
| 362 | - | if err := d.waitForTargetRestore(target.Name); err != nil { | |
| 363 | + | ctx := d.serveCtx | |
| 364 | + | if ctx == nil { | |
| 365 | + | ctx = context.Background() | |
| 366 | + | } | |
| 367 | + | if err := d.waitForTargetRestore(ctx, target.Name); err != nil { | |
| 363 | 368 | d.invalidateRender(from, ac, true, "picker.go") | |
| 364 | 369 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err) | |
| 365 | 370 | } | |
| @@ -515,8 +520,8 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 515 | 520 | // the handoff while d.mu is held. Creation failure leaves the source client | |
| 516 | 521 | // and stopped record untouched. | |
| 517 | 522 | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) { | |
| 518 | - | if stopped.record.Name != "" && (stopped.state != runtimeFresh || stopped.record.RecoveryState != domain.RecoveryFresh) { | |
| 519 | - | return nil, nil, false, &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + target.Name} | |
| 523 | + | if stopped.record.Name != "" && stopped.state == ports.SessionBroken { | |
| 524 | + | return nil, nil, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name} | |
| 520 | 525 | } | |
| 521 | 526 | ||
| 522 | 527 | // The caller already holds d.mu. Keep handoff ownership atomic with global | |
| @@ -2,6 +2,7 @@ | |||||
| 2 | package daemon | 2 | package daemon | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "context" | |||
| 5 | "sort" | 6 | "sort" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| @@ -359,7 +360,11 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker | |||||
| 359 | // as part of the source ownership transfer. | 360 | // as part of the source ownership transfer. | ||
| 360 | func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error { | 361 | func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error { | ||
| 361 | if target.Name != "" { | 362 | if target.Name != "" { | ||
| 362 | - | if err := d.waitForTargetRestore(target.Name); err != nil { | 363 | + | ctx := d.serveCtx |
| 364 | + | if ctx == nil { | |||
| 365 | + | ctx = context.Background() | |||
| 366 | + | } | |||
| 367 | + | if err := d.waitForTargetRestore(ctx, target.Name); err != nil { | |||
| 363 | d.invalidateRender(from, ac, true, "picker.go") | 368 | d.invalidateRender(from, ac, true, "picker.go") | ||
| 364 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err) | 369 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err) | ||
| 365 | } | 370 | } | ||
| @@ -515,8 +520,8 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||||
| 515 | // the handoff while d.mu is held. Creation failure leaves the source client | 520 | // the handoff while d.mu is held. Creation failure leaves the source client | ||
| 516 | // and stopped record untouched. | 521 | // and stopped record untouched. | ||
| 517 | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) { | 522 | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) { | ||
| 518 | - | if stopped.record.Name != "" && (stopped.state != runtimeFresh || stopped.record.RecoveryState != domain.RecoveryFresh) { | 523 | + | if stopped.record.Name != "" && stopped.state == ports.SessionBroken { |
| 519 | - | return nil, nil, false, &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + target.Name} | 524 | + | return nil, nil, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name} |
| 520 | } | 525 | } | ||
| 521 | 526 | ||||
| 522 | // The caller already holds d.mu. Keep handoff ownership atomic with global | 527 | // The caller already holds d.mu. Keep handoff ownership atomic with global | ||
M internal/usecase/daemon/picker_test.go +12 -13
| @@ -226,7 +226,7 @@ func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) { | |||
| 226 | 226 | d, from, ac, _ := newManualSessionWithPTYs(t, p) | |
| 227 | 227 | record := durableRecoveryRecord(0) | |
| 228 | 228 | record.Name = "restoring" | |
| 229 | - | state, done := initialRuntimeRecoveryState(record) | |
| 229 | + | state, done := initialSessionState(record) | |
| 230 | 230 | d.stopped[record.Name] = stoppedSession{ | |
| 231 | 231 | name: record.Name, | |
| 232 | 232 | createdAt: record.CreatedAt, | |
| @@ -296,7 +296,7 @@ func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) { | |||
| 296 | 296 | d.mu.Lock() | |
| 297 | 297 | entry := d.stopped[record.Name] | |
| 298 | 298 | d.mu.Unlock() | |
| 299 | - | require.Equal(t, runtimeDegraded, entry.state) | |
| 299 | + | require.Equal(t, ports.SessionBroken, entry.state) | |
| 300 | 300 | require.Equal(t, "restore interrupted", entry.record.DegradedReason) | |
| 301 | 301 | select { | |
| 302 | 302 | case <-entry.restoreDone: | |
| @@ -308,13 +308,11 @@ func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) { | |||
| 308 | 308 | func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) { | |
| 309 | 309 | tests := []struct { | |
| 310 | 310 | name string | |
| 311 | - | recovery domain.RecoveryState | |
| 312 | - | runtimeState runtimeRecoveryState | |
| 311 | + | broken bool | |
| 312 | + | runtimeState ports.SessionState | |
| 313 | 313 | }{ | |
| 314 | - | {name: "degraded", recovery: domain.RecoveryDegraded, runtimeState: runtimeDegraded}, | |
| 315 | - | {name: "deleting", recovery: domain.RecoveryDeleting, runtimeState: runtimeDeleting}, | |
| 316 | - | {name: "healthy with missing runtime state", recovery: domain.RecoveryHealthy}, | |
| 317 | - | {name: "healthy without restored runtime", recovery: domain.RecoveryHealthy, runtimeState: runtimeHealthy}, | |
| 314 | + | {name: "broken", broken: true, runtimeState: ports.SessionBroken}, | |
| 315 | + | {name: "healthy without restored runtime", runtimeState: ports.SessionStopped}, | |
| 318 | 316 | } | |
| 319 | 317 | ||
| 320 | 318 | for _, tt := range tests { | |
| @@ -325,7 +323,9 @@ func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) { | |||
| 325 | 323 | store, storeState := newMockStore(t) | |
| 326 | 324 | record := durableRecoveryRecord(0) | |
| 327 | 325 | record.Name = "unsafe" | |
| 328 | - | record.RecoveryState = tt.recovery | |
| 326 | + | if tt.broken { | |
| 327 | + | record.DegradedReason = "checkpoint validation failed" | |
| 328 | + | } | |
| 329 | 329 | catalogue := persist.New(store) | |
| 330 | 330 | WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d) | |
| 331 | 331 | d.stopped[record.Name] = stoppedSession{ | |
| @@ -345,7 +345,7 @@ func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) { | |||
| 345 | 345 | require.Equal(t, domain.NoticeSessionUnavailable, userError.Code) | |
| 346 | 346 | var protocolError *protoErr | |
| 347 | 347 | require.ErrorAs(t, err, &protocolError) | |
| 348 | - | require.Equal(t, ports.ErrSessionDegraded, protocolError.code) | |
| 348 | + | require.Equal(t, ports.ErrInternal, protocolError.code) | |
| 349 | 349 | require.Zero(t, factory.calls.Load(), "unsafe target must not open a PTY") | |
| 350 | 350 | require.Same(t, from, ac.currentSession()) | |
| 351 | 351 | ||
| @@ -368,7 +368,7 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) { | |||
| 368 | 368 | defer release3() | |
| 369 | 369 | d, from, ac, sends := newManualSessionWithPTYs(t, p1) | |
| 370 | 370 | d.ptys = newFactorySeq(t, p2, p3) | |
| 371 | - | d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}, record: domain.CatalogueRecord{Name: "work", RecoveryState: domain.RecoveryFresh}, state: runtimeFresh} | |
| 371 | + | d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}, record: domain.CatalogueRecord{Name: "work"}, state: ports.SessionStopped} | |
| 372 | 372 | ||
| 373 | 373 | d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true}) | |
| 374 | 374 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -1142,7 +1142,6 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t | |||
| 1142 | 1142 | } | |
| 1143 | 1143 | record := target.persistRecordLocked(1) | |
| 1144 | 1144 | target.mu.Unlock() | |
| 1145 | - | record.RecoveryState = domain.RecoveryFresh | |
| 1146 | 1145 | require.NoError(t, d.catalogue.Create(record)) | |
| 1147 | 1146 | ||
| 1148 | 1147 | d.enterPicker(from, ac) | |
| @@ -1178,7 +1177,7 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | |||
| 1178 | 1177 | cause := errors.New("delete failed") | |
| 1179 | 1178 | store, state := newMockStore(t) | |
| 1180 | 1179 | WithStore(t, store)(d) | |
| 1181 | - | record := domain.CatalogueRecord{Name: "stopped", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/stopped", CreatedAt: 7, RecoveryState: domain.RecoveryFresh} | |
| 1180 | + | record := domain.CatalogueRecord{Name: "stopped", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/stopped", CreatedAt: 7} | |
| 1182 | 1181 | require.NoError(t, d.catalogue.Create(record)) | |
| 1183 | 1182 | state.mu.Lock() | |
| 1184 | 1183 | state.deleteErr = func(string) error { return cause } | |
| @@ -226,7 +226,7 @@ func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) { | |||||
| 226 | d, from, ac, _ := newManualSessionWithPTYs(t, p) | 226 | d, from, ac, _ := newManualSessionWithPTYs(t, p) | ||
| 227 | record := durableRecoveryRecord(0) | 227 | record := durableRecoveryRecord(0) | ||
| 228 | record.Name = "restoring" | 228 | record.Name = "restoring" | ||
| 229 | - | state, done := initialRuntimeRecoveryState(record) | 229 | + | state, done := initialSessionState(record) |
| 230 | d.stopped[record.Name] = stoppedSession{ | 230 | d.stopped[record.Name] = stoppedSession{ | ||
| 231 | name: record.Name, | 231 | name: record.Name, | ||
| 232 | createdAt: record.CreatedAt, | 232 | createdAt: record.CreatedAt, | ||
| @@ -296,7 +296,7 @@ func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) { | |||||
| 296 | d.mu.Lock() | 296 | d.mu.Lock() | ||
| 297 | entry := d.stopped[record.Name] | 297 | entry := d.stopped[record.Name] | ||
| 298 | d.mu.Unlock() | 298 | d.mu.Unlock() | ||
| 299 | - | require.Equal(t, runtimeDegraded, entry.state) | 299 | + | require.Equal(t, ports.SessionBroken, entry.state) |
| 300 | require.Equal(t, "restore interrupted", entry.record.DegradedReason) | 300 | require.Equal(t, "restore interrupted", entry.record.DegradedReason) | ||
| 301 | select { | 301 | select { | ||
| 302 | case <-entry.restoreDone: | 302 | case <-entry.restoreDone: | ||
| @@ -308,13 +308,11 @@ func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) { | |||||
| 308 | func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) { | 308 | func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) { | ||
| 309 | tests := []struct { | 309 | tests := []struct { | ||
| 310 | name string | 310 | name string | ||
| 311 | - | recovery domain.RecoveryState | 311 | + | broken bool |
| 312 | - | runtimeState runtimeRecoveryState | 312 | + | runtimeState ports.SessionState |
| 313 | }{ | 313 | }{ | ||
| 314 | - | {name: "degraded", recovery: domain.RecoveryDegraded, runtimeState: runtimeDegraded}, | 314 | + | {name: "broken", broken: true, runtimeState: ports.SessionBroken}, |
| 315 | - | {name: "deleting", recovery: domain.RecoveryDeleting, runtimeState: runtimeDeleting}, | 315 | + | {name: "healthy without restored runtime", runtimeState: ports.SessionStopped}, |
| 316 | - | {name: "healthy with missing runtime state", recovery: domain.RecoveryHealthy}, | |||
| 317 | - | {name: "healthy without restored runtime", recovery: domain.RecoveryHealthy, runtimeState: runtimeHealthy}, | |||
| 318 | } | 316 | } | ||
| 319 | 317 | ||||
| 320 | for _, tt := range tests { | 318 | for _, tt := range tests { | ||
| @@ -325,7 +323,9 @@ func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) { | |||||
| 325 | store, storeState := newMockStore(t) | 323 | store, storeState := newMockStore(t) | ||
| 326 | record := durableRecoveryRecord(0) | 324 | record := durableRecoveryRecord(0) | ||
| 327 | record.Name = "unsafe" | 325 | record.Name = "unsafe" | ||
| 328 | - | record.RecoveryState = tt.recovery | 326 | + | if tt.broken { |
| 327 | + | record.DegradedReason = "checkpoint validation failed" | |||
| 328 | + | } | |||
| 329 | catalogue := persist.New(store) | 329 | catalogue := persist.New(store) | ||
| 330 | WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d) | 330 | WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d) | ||
| 331 | d.stopped[record.Name] = stoppedSession{ | 331 | d.stopped[record.Name] = stoppedSession{ | ||
| @@ -345,7 +345,7 @@ func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) { | |||||
| 345 | require.Equal(t, domain.NoticeSessionUnavailable, userError.Code) | 345 | require.Equal(t, domain.NoticeSessionUnavailable, userError.Code) | ||
| 346 | var protocolError *protoErr | 346 | var protocolError *protoErr | ||
| 347 | require.ErrorAs(t, err, &protocolError) | 347 | require.ErrorAs(t, err, &protocolError) | ||
| 348 | - | require.Equal(t, ports.ErrSessionDegraded, protocolError.code) | 348 | + | require.Equal(t, ports.ErrInternal, protocolError.code) |
| 349 | require.Zero(t, factory.calls.Load(), "unsafe target must not open a PTY") | 349 | require.Zero(t, factory.calls.Load(), "unsafe target must not open a PTY") | ||
| 350 | require.Same(t, from, ac.currentSession()) | 350 | require.Same(t, from, ac.currentSession()) | ||
| 351 | 351 | ||||
| @@ -368,7 +368,7 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) { | |||||
| 368 | defer release3() | 368 | defer release3() | ||
| 369 | d, from, ac, sends := newManualSessionWithPTYs(t, p1) | 369 | d, from, ac, sends := newManualSessionWithPTYs(t, p1) | ||
| 370 | d.ptys = newFactorySeq(t, p2, p3) | 370 | d.ptys = newFactorySeq(t, p2, p3) | ||
| 371 | - | d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}, record: domain.CatalogueRecord{Name: "work", RecoveryState: domain.RecoveryFresh}, state: runtimeFresh} | 371 | + | d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}, record: domain.CatalogueRecord{Name: "work"}, state: ports.SessionStopped} |
| 372 | 372 | ||||
| 373 | d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true}) | 373 | d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true}) | ||
| 374 | awaitFrame(t, sends, ports.MsgOutput) | 374 | awaitFrame(t, sends, ports.MsgOutput) | ||
| @@ -1142,7 +1142,6 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t | |||||
| 1142 | } | 1142 | } | ||
| 1143 | record := target.persistRecordLocked(1) | 1143 | record := target.persistRecordLocked(1) | ||
| 1144 | target.mu.Unlock() | 1144 | target.mu.Unlock() | ||
| 1145 | - | record.RecoveryState = domain.RecoveryFresh | |||
| 1146 | require.NoError(t, d.catalogue.Create(record)) | 1145 | require.NoError(t, d.catalogue.Create(record)) | ||
| 1147 | 1146 | ||||
| 1148 | d.enterPicker(from, ac) | 1147 | d.enterPicker(from, ac) | ||
| @@ -1178,7 +1177,7 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | |||||
| 1178 | cause := errors.New("delete failed") | 1177 | cause := errors.New("delete failed") | ||
| 1179 | store, state := newMockStore(t) | 1178 | store, state := newMockStore(t) | ||
| 1180 | WithStore(t, store)(d) | 1179 | WithStore(t, store)(d) | ||
| 1181 | - | record := domain.CatalogueRecord{Name: "stopped", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/stopped", CreatedAt: 7, RecoveryState: domain.RecoveryFresh} | 1180 | + | record := domain.CatalogueRecord{Name: "stopped", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/stopped", CreatedAt: 7} |
| 1182 | require.NoError(t, d.catalogue.Create(record)) | 1181 | require.NoError(t, d.catalogue.Create(record)) | ||
| 1183 | state.mu.Lock() | 1182 | state.mu.Lock() | ||
| 1184 | state.deleteErr = func(string) error { return cause } | 1183 | state.deleteErr = func(string) error { return cause } | ||
M internal/usecase/daemon/recovery_observability.go +8 -8
| @@ -31,22 +31,22 @@ func (d *Daemon) logStartupRecoveryCounts(restoring int) { | |||
| 31 | 31 | } else { | |
| 32 | 32 | records = d.stoppedRecords() | |
| 33 | 33 | } | |
| 34 | - | healthy, fresh, degraded := 0, 0, 0 | |
| 34 | + | healthy, fresh, broken := 0, 0, 0 | |
| 35 | 35 | for _, record := range records { | |
| 36 | - | switch record.RecoveryState { | |
| 37 | - | case domain.RecoveryHealthy: | |
| 38 | - | healthy++ | |
| 39 | - | case domain.RecoveryFresh: | |
| 36 | + | switch { | |
| 37 | + | case record.DegradedReason != "": | |
| 38 | + | broken++ | |
| 39 | + | case record.Committed == nil: | |
| 40 | 40 | fresh++ | |
| 41 | - | case domain.RecoveryDegraded: | |
| 42 | - | degraded++ | |
| 41 | + | default: | |
| 42 | + | healthy++ | |
| 43 | 43 | } | |
| 44 | 44 | } | |
| 45 | 45 | d.log.Info("daemon_startup_complete", | |
| 46 | 46 | "healthy", healthy, | |
| 47 | 47 | "fresh", fresh, | |
| 48 | 48 | "restoring", restoring, | |
| 49 | - | "degraded", degraded, | |
| 49 | + | "broken", broken, | |
| 50 | 50 | ) | |
| 51 | 51 | } | |
| 52 | 52 | ||
| @@ -31,22 +31,22 @@ func (d *Daemon) logStartupRecoveryCounts(restoring int) { | |||||
| 31 | } else { | 31 | } else { | ||
| 32 | records = d.stoppedRecords() | 32 | records = d.stoppedRecords() | ||
| 33 | } | 33 | } | ||
| 34 | - | healthy, fresh, degraded := 0, 0, 0 | 34 | + | healthy, fresh, broken := 0, 0, 0 |
| 35 | for _, record := range records { | 35 | for _, record := range records { | ||
| 36 | - | switch record.RecoveryState { | 36 | + | switch { |
| 37 | - | case domain.RecoveryHealthy: | 37 | + | case record.DegradedReason != "": |
| 38 | - | healthy++ | 38 | + | broken++ |
| 39 | - | case domain.RecoveryFresh: | 39 | + | case record.Committed == nil: |
| 40 | fresh++ | 40 | fresh++ | ||
| 41 | - | case domain.RecoveryDegraded: | 41 | + | default: |
| 42 | - | degraded++ | 42 | + | healthy++ |
| 43 | } | 43 | } | ||
| 44 | } | 44 | } | ||
| 45 | d.log.Info("daemon_startup_complete", | 45 | d.log.Info("daemon_startup_complete", | ||
| 46 | "healthy", healthy, | 46 | "healthy", healthy, | ||
| 47 | "fresh", fresh, | 47 | "fresh", fresh, | ||
| 48 | "restoring", restoring, | 48 | "restoring", restoring, | ||
| 49 | - | "degraded", degraded, | 49 | + | "broken", broken, |
| 50 | ) | 50 | ) | ||
| 51 | } | 51 | } | ||
| 52 | 52 | ||||
M internal/usecase/daemon/recovery_observability_test.go +8 -7
| @@ -15,11 +15,12 @@ import ( | |||
| 15 | 15 | func TestStartupRecoveryCounts(t *testing.T) { | |
| 16 | 16 | var buffer bytes.Buffer | |
| 17 | 17 | log := slog.New(slog.NewJSONHandler(&buffer, nil)) | |
| 18 | + | ref := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}} | |
| 18 | 19 | records := []domain.CatalogueRecord{ | |
| 19 | - | {Name: "one", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryHealthy}, | |
| 20 | - | {Name: "two", IncarnationID: domain.IncarnationID{2}, RecoveryState: domain.RecoveryHealthy}, | |
| 21 | - | {Name: "new", IncarnationID: domain.IncarnationID{3}, RecoveryState: domain.RecoveryFresh}, | |
| 22 | - | {Name: "broken", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDegraded, DegradedReason: "terminal contents must not appear"}, | |
| 20 | + | {Name: "one", IncarnationID: domain.IncarnationID{1}, Committed: ref}, | |
| 21 | + | {Name: "two", IncarnationID: domain.IncarnationID{2}, Committed: ref}, | |
| 22 | + | {Name: "new", IncarnationID: domain.IncarnationID{3}}, | |
| 23 | + | {Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: ref, DegradedReason: "terminal contents must not appear"}, | |
| 23 | 24 | } | |
| 24 | 25 | d := New(nil, stubClock{}, log, WithCatalogue(newDurableRecoveryCatalogue(records), records)) | |
| 25 | 26 | d.logSessionRestoreComplete(records[0], 7, false) | |
| @@ -38,7 +39,7 @@ func TestStartupRecoveryCounts(t *testing.T) { | |||
| 38 | 39 | require.EqualValues(t, 2, startup["healthy"]) | |
| 39 | 40 | require.EqualValues(t, 1, startup["fresh"]) | |
| 40 | 41 | require.EqualValues(t, 0, startup["restoring"]) | |
| 41 | - | require.EqualValues(t, 1, startup["degraded"]) | |
| 42 | + | require.EqualValues(t, 1, startup["broken"]) | |
| 42 | 43 | } | |
| 43 | 44 | ||
| 44 | 45 | type failingRecordsCatalogue struct { | |
| @@ -52,7 +53,7 @@ func (c failingRecordsCatalogue) Records() ([]domain.CatalogueRecord, error) { | |||
| 52 | 53 | ||
| 53 | 54 | func TestStartupRecoveryCountsFallsBackWhenCatalogueReadFails(t *testing.T) { | |
| 54 | 55 | var buffer bytes.Buffer | |
| 55 | - | record := domain.CatalogueRecord{Name: "broken", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDegraded, DegradedReason: "invalid"} | |
| 56 | + | record := domain.CatalogueRecord{Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}, DegradedReason: "invalid"} | |
| 56 | 57 | d := New(nil, stubClock{}, slog.New(slog.NewJSONHandler(&buffer, nil))) | |
| 57 | 58 | d.persistEnabled = true | |
| 58 | 59 | d.catalogue = failingRecordsCatalogue{durableRecoveryCatalogue: newDurableRecoveryCatalogue(nil), err: errors.New("read failed")} | |
| @@ -63,7 +64,7 @@ func TestStartupRecoveryCountsFallsBackWhenCatalogueReadFails(t *testing.T) { | |||
| 63 | 64 | entries := daemonJSONLogs(t, buffer.Bytes()) | |
| 64 | 65 | daemonRequireEvent(t, entries, "daemon_startup_catalogue_read_failed") | |
| 65 | 66 | startup := daemonRequireEvent(t, entries, "daemon_startup_complete") | |
| 66 | - | require.EqualValues(t, 1, startup["degraded"]) | |
| 67 | + | require.EqualValues(t, 1, startup["broken"]) | |
| 67 | 68 | } | |
| 68 | 69 | ||
| 69 | 70 | func daemonJSONLogs(t *testing.T, data []byte) []map[string]any { | |
| @@ -15,11 +15,12 @@ import ( | |||||
| 15 | func TestStartupRecoveryCounts(t *testing.T) { | 15 | func TestStartupRecoveryCounts(t *testing.T) { | ||
| 16 | var buffer bytes.Buffer | 16 | var buffer bytes.Buffer | ||
| 17 | log := slog.New(slog.NewJSONHandler(&buffer, nil)) | 17 | log := slog.New(slog.NewJSONHandler(&buffer, nil)) | ||
| 18 | + | ref := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}} | |||
| 18 | records := []domain.CatalogueRecord{ | 19 | records := []domain.CatalogueRecord{ | ||
| 19 | - | {Name: "one", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryHealthy}, | 20 | + | {Name: "one", IncarnationID: domain.IncarnationID{1}, Committed: ref}, |
| 20 | - | {Name: "two", IncarnationID: domain.IncarnationID{2}, RecoveryState: domain.RecoveryHealthy}, | 21 | + | {Name: "two", IncarnationID: domain.IncarnationID{2}, Committed: ref}, |
| 21 | - | {Name: "new", IncarnationID: domain.IncarnationID{3}, RecoveryState: domain.RecoveryFresh}, | 22 | + | {Name: "new", IncarnationID: domain.IncarnationID{3}}, |
| 22 | - | {Name: "broken", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDegraded, DegradedReason: "terminal contents must not appear"}, | 23 | + | {Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: ref, DegradedReason: "terminal contents must not appear"}, |
| 23 | } | 24 | } | ||
| 24 | d := New(nil, stubClock{}, log, WithCatalogue(newDurableRecoveryCatalogue(records), records)) | 25 | d := New(nil, stubClock{}, log, WithCatalogue(newDurableRecoveryCatalogue(records), records)) | ||
| 25 | d.logSessionRestoreComplete(records[0], 7, false) | 26 | d.logSessionRestoreComplete(records[0], 7, false) | ||
| @@ -38,7 +39,7 @@ func TestStartupRecoveryCounts(t *testing.T) { | |||||
| 38 | require.EqualValues(t, 2, startup["healthy"]) | 39 | require.EqualValues(t, 2, startup["healthy"]) | ||
| 39 | require.EqualValues(t, 1, startup["fresh"]) | 40 | require.EqualValues(t, 1, startup["fresh"]) | ||
| 40 | require.EqualValues(t, 0, startup["restoring"]) | 41 | require.EqualValues(t, 0, startup["restoring"]) | ||
| 41 | - | require.EqualValues(t, 1, startup["degraded"]) | 42 | + | require.EqualValues(t, 1, startup["broken"]) |
| 42 | } | 43 | } | ||
| 43 | 44 | ||||
| 44 | type failingRecordsCatalogue struct { | 45 | type failingRecordsCatalogue struct { | ||
| @@ -52,7 +53,7 @@ func (c failingRecordsCatalogue) Records() ([]domain.CatalogueRecord, error) { | |||||
| 52 | 53 | ||||
| 53 | func TestStartupRecoveryCountsFallsBackWhenCatalogueReadFails(t *testing.T) { | 54 | func TestStartupRecoveryCountsFallsBackWhenCatalogueReadFails(t *testing.T) { | ||
| 54 | var buffer bytes.Buffer | 55 | var buffer bytes.Buffer | ||
| 55 | - | record := domain.CatalogueRecord{Name: "broken", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDegraded, DegradedReason: "invalid"} | 56 | + | record := domain.CatalogueRecord{Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}, DegradedReason: "invalid"} |
| 56 | d := New(nil, stubClock{}, slog.New(slog.NewJSONHandler(&buffer, nil))) | 57 | d := New(nil, stubClock{}, slog.New(slog.NewJSONHandler(&buffer, nil))) | ||
| 57 | d.persistEnabled = true | 58 | d.persistEnabled = true | ||
| 58 | d.catalogue = failingRecordsCatalogue{durableRecoveryCatalogue: newDurableRecoveryCatalogue(nil), err: errors.New("read failed")} | 59 | d.catalogue = failingRecordsCatalogue{durableRecoveryCatalogue: newDurableRecoveryCatalogue(nil), err: errors.New("read failed")} | ||
| @@ -63,7 +64,7 @@ func TestStartupRecoveryCountsFallsBackWhenCatalogueReadFails(t *testing.T) { | |||||
| 63 | entries := daemonJSONLogs(t, buffer.Bytes()) | 64 | entries := daemonJSONLogs(t, buffer.Bytes()) | ||
| 64 | daemonRequireEvent(t, entries, "daemon_startup_catalogue_read_failed") | 65 | daemonRequireEvent(t, entries, "daemon_startup_catalogue_read_failed") | ||
| 65 | startup := daemonRequireEvent(t, entries, "daemon_startup_complete") | 66 | startup := daemonRequireEvent(t, entries, "daemon_startup_complete") | ||
| 66 | - | require.EqualValues(t, 1, startup["degraded"]) | 67 | + | require.EqualValues(t, 1, startup["broken"]) |
| 67 | } | 68 | } | ||
| 68 | 69 | ||||
| 69 | func daemonJSONLogs(t *testing.T, data []byte) []map[string]any { | 70 | func daemonJSONLogs(t *testing.T, data []byte) []map[string]any { | ||
M internal/usecase/daemon/session.go +2 -2
| @@ -320,7 +320,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 320 | 320 | sess.snapEligible.Store(!ephemeral && name != "") | |
| 321 | 321 | if !ephemeral { | |
| 322 | 322 | if d.persistEnabled { | |
| 323 | - | record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names, RecoveryState: domain.RecoveryFresh} | |
| 323 | + | record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names} | |
| 324 | 324 | if d.recovery == nil { | |
| 325 | 325 | closeTabs(tabs) | |
| 326 | 326 | cancel() | |
| @@ -901,7 +901,7 @@ func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord { | |||
| 901 | 901 | } else { | |
| 902 | 902 | tabNames = tabNames[:lastCustom+1] | |
| 903 | 903 | } | |
| 904 | - | return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames, RecoveryState: domain.RecoveryFresh} | |
| 904 | + | return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames} | |
| 905 | 905 | } | |
| 906 | 906 | ||
| 907 | 907 | func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error { | |
| @@ -320,7 +320,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||||
| 320 | sess.snapEligible.Store(!ephemeral && name != "") | 320 | sess.snapEligible.Store(!ephemeral && name != "") | ||
| 321 | if !ephemeral { | 321 | if !ephemeral { | ||
| 322 | if d.persistEnabled { | 322 | if d.persistEnabled { | ||
| 323 | - | record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names, RecoveryState: domain.RecoveryFresh} | 323 | + | record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names} |
| 324 | if d.recovery == nil { | 324 | if d.recovery == nil { | ||
| 325 | closeTabs(tabs) | 325 | closeTabs(tabs) | ||
| 326 | cancel() | 326 | cancel() | ||
| @@ -901,7 +901,7 @@ func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord { | |||||
| 901 | } else { | 901 | } else { | ||
| 902 | tabNames = tabNames[:lastCustom+1] | 902 | tabNames = tabNames[:lastCustom+1] | ||
| 903 | } | 903 | } | ||
| 904 | - | return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames, RecoveryState: domain.RecoveryFresh} | 904 | + | return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames} |
| 905 | } | 905 | } | ||
| 906 | 906 | ||||
| 907 | func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error { | 907 | func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error { | ||
M internal/usecase/daemon/session_purge_control_test.go +1 -1
| @@ -16,7 +16,7 @@ func TestStoppedPurgeMetadataFailureRemainsFencedForRetry(t *testing.T) { | |||
| 16 | 16 | WithSnapshotRepository(repository)(d) | |
| 17 | 17 | store, state := newMockStore(t) | |
| 18 | 18 | WithStore(t, store)(d) | |
| 19 | - | record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh} | |
| 19 | + | record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}} | |
| 20 | 20 | require.NoError(t, d.catalogue.Create(record)) | |
| 21 | 21 | metadataErr := errors.New("metadata delete failed") | |
| 22 | 22 | state.mu.Lock() | |
| @@ -16,7 +16,7 @@ func TestStoppedPurgeMetadataFailureRemainsFencedForRetry(t *testing.T) { | |||||
| 16 | WithSnapshotRepository(repository)(d) | 16 | WithSnapshotRepository(repository)(d) | ||
| 17 | store, state := newMockStore(t) | 17 | store, state := newMockStore(t) | ||
| 18 | WithStore(t, store)(d) | 18 | WithStore(t, store)(d) | ||
| 19 | - | record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh} | 19 | + | record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}} |
| 20 | require.NoError(t, d.catalogue.Create(record)) | 20 | require.NoError(t, d.catalogue.Create(record)) | ||
| 21 | metadataErr := errors.New("metadata delete failed") | 21 | metadataErr := errors.New("metadata delete failed") | ||
| 22 | state.mu.Lock() | 22 | state.mu.Lock() | ||
M internal/usecase/daemon/session_purge_retry_test.go +0 -1
| @@ -34,7 +34,6 @@ func TestLivePurgeLeavesFailedDirectoryDeletionForStartupGarbageCollection(t *te | |||
| 34 | 34 | WithStore(t, store)(d) | |
| 35 | 35 | sess := newSnapshotTestSession(t, "work", false, "/work") | |
| 36 | 36 | record := sess.persistRecordLocked(1) | |
| 37 | - | record.RecoveryState = domain.RecoveryFresh | |
| 38 | 37 | require.NoError(t, d.catalogue.Create(record)) | |
| 39 | 38 | pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 40 | 39 | require.True(t, ok) | |
| @@ -34,7 +34,6 @@ func TestLivePurgeLeavesFailedDirectoryDeletionForStartupGarbageCollection(t *te | |||||
| 34 | WithStore(t, store)(d) | 34 | WithStore(t, store)(d) | ||
| 35 | sess := newSnapshotTestSession(t, "work", false, "/work") | 35 | sess := newSnapshotTestSession(t, "work", false, "/work") | ||
| 36 | record := sess.persistRecordLocked(1) | 36 | record := sess.persistRecordLocked(1) | ||
| 37 | - | record.RecoveryState = domain.RecoveryFresh | |||
| 38 | require.NoError(t, d.catalogue.Create(record)) | 37 | require.NoError(t, d.catalogue.Create(record)) | ||
| 39 | pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | 38 | pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | ||
| 40 | require.True(t, ok) | 39 | require.True(t, ok) | ||
M internal/usecase/daemon/session_test.go +10 -12
| @@ -869,7 +869,7 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) { | |||
| 869 | 869 | store, _ := newMockStore(t) | |
| 870 | 870 | seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store)) | |
| 871 | 871 | incarnation := domain.IncarnationID{1} | |
| 872 | - | require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "work", IncarnationID: incarnation, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9, RecoveryState: domain.RecoveryFresh})) | |
| 872 | + | require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "work", IncarnationID: incarnation, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9})) | |
| 873 | 873 | ||
| 874 | 874 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store)) | |
| 875 | 875 | d.mu.Lock() | |
| @@ -880,8 +880,7 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) { | |||
| 880 | 880 | require.Equal(t, int64(7), stopped.createdAt) | |
| 881 | 881 | require.Equal(t, incarnation, stopped.incarnation) | |
| 882 | 882 | require.Equal(t, uint64(9), stopped.lastUsedSeq) | |
| 883 | - | require.Equal(t, domain.RecoveryFresh, stopped.record.RecoveryState) | |
| 884 | - | require.Equal(t, runtimeFresh, stopped.state) | |
| 883 | + | require.Equal(t, ports.SessionStopped, stopped.state) | |
| 885 | 884 | require.NotNil(t, stopped.restoreDone) | |
| 886 | 885 | require.Equal(t, uint64(9), d.mruSeq.Load()) | |
| 887 | 886 | } | |
| @@ -951,7 +950,7 @@ func TestTouchMRUPersistsNamedButNotEphemeral(t *testing.T) { | |||
| 951 | 950 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 952 | 951 | WithStore(t, store)(d) | |
| 953 | 952 | named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1, incarnation: domain.IncarnationID{1}} | |
| 954 | - | require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryFresh})) | |
| 953 | + | require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1})) | |
| 955 | 954 | ||
| 956 | 955 | d.touchMRU(named) | |
| 957 | 956 | require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq) | |
| @@ -994,7 +993,6 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) { | |||
| 994 | 993 | require.True(t, state.has("work")) | |
| 995 | 994 | created := state.record(t, "work") | |
| 996 | 995 | require.NotEqual(t, domain.IncarnationID{}, created.IncarnationID) | |
| 997 | - | require.Equal(t, domain.RecoveryFresh, created.RecoveryState) | |
| 998 | 996 | require.NoError(t, created.Validate()) | |
| 999 | 997 | require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell")) | |
| 1000 | 998 | require.NoError(t, d.createTab(sess, sz)) | |
| @@ -1142,7 +1140,7 @@ func TestAttachRestoresPersistedTabNames(t *testing.T) { | |||
| 1142 | 1140 | store, _ := newMockStore(t) | |
| 1143 | 1141 | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | |
| 1144 | 1142 | WithStore(t, store)(d) | |
| 1145 | - | require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}, RecoveryState: domain.RecoveryFresh})) | |
| 1143 | + | require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}})) | |
| 1146 | 1144 | d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}} | |
| 1147 | 1145 | tr := portsmocks.NewMockTransport(t) | |
| 1148 | 1146 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| @@ -1356,7 +1354,7 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) { | |||
| 1356 | 1354 | store, state := newMockStore(t) | |
| 1357 | 1355 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 1358 | 1356 | WithStore(t, store)(d) | |
| 1359 | - | require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryFresh})) | |
| 1357 | + | require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1})) | |
| 1360 | 1358 | d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1} | |
| 1361 | 1359 | require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true})) | |
| 1362 | 1360 | require.False(t, state.has("old")) | |
| @@ -1837,15 +1835,15 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) { | |||
| 1837 | 1835 | alphaRef := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}} | |
| 1838 | 1836 | workRef := &domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}} | |
| 1839 | 1837 | records := []domain.CatalogueRecord{ | |
| 1840 | - | {Name: "alpha", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/alpha", CreatedAt: 7, LastUsedSeq: 11, TabNames: []string{"shell"}, RecoveryState: domain.RecoveryHealthy, Committed: alphaRef}, | |
| 1841 | - | {Name: "work", IncarnationID: domain.IncarnationID{2}, Cwd: "/tmp/work", CreatedAt: 9, LastUsedSeq: 13, TabNames: []string{"editor", "logs"}, RecoveryState: domain.RecoveryDegraded, Committed: workRef, DegradedReason: "uncertain legacy checkpoint"}, | |
| 1838 | + | {Name: "alpha", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/alpha", CreatedAt: 7, LastUsedSeq: 11, TabNames: []string{"shell"}, Committed: alphaRef}, | |
| 1839 | + | {Name: "work", IncarnationID: domain.IncarnationID{2}, Cwd: "/tmp/work", CreatedAt: 9, LastUsedSeq: 13, TabNames: []string{"editor", "logs"}, Committed: workRef, DegradedReason: "uncertain legacy checkpoint"}, | |
| 1842 | 1840 | } | |
| 1843 | 1841 | ||
| 1844 | 1842 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(persist.New(nil), records)) | |
| 1845 | 1843 | ||
| 1846 | 1844 | alpha := d.stopped["alpha"] | |
| 1847 | 1845 | require.Equal(t, records[0], alpha.record) | |
| 1848 | - | require.Equal(t, runtimeRestoring, alpha.state) | |
| 1846 | + | require.Equal(t, ports.SessionStopped, alpha.state) | |
| 1849 | 1847 | require.NotNil(t, alpha.restoreDone) | |
| 1850 | 1848 | select { | |
| 1851 | 1849 | case <-alpha.restoreDone: | |
| @@ -1854,7 +1852,7 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) { | |||
| 1854 | 1852 | } | |
| 1855 | 1853 | work := d.stopped["work"] | |
| 1856 | 1854 | require.Equal(t, records[1], work.record) | |
| 1857 | - | require.Equal(t, runtimeDegraded, work.state) | |
| 1855 | + | require.Equal(t, ports.SessionBroken, work.state) | |
| 1858 | 1856 | require.NotNil(t, work.restoreDone) | |
| 1859 | 1857 | select { | |
| 1860 | 1858 | case <-work.restoreDone: | |
| @@ -1868,7 +1866,7 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) { | |||
| 1868 | 1866 | func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) { | |
| 1869 | 1867 | store, _ := newMockStore(t) | |
| 1870 | 1868 | seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store)) | |
| 1871 | - | require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900, RecoveryState: domain.RecoveryFresh})) | |
| 1869 | + | require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900})) | |
| 1872 | 1870 | ||
| 1873 | 1871 | p, release := newBlockingPTY(t) | |
| 1874 | 1872 | defer release() | |
| @@ -869,7 +869,7 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) { | |||||
| 869 | store, _ := newMockStore(t) | 869 | store, _ := newMockStore(t) | ||
| 870 | seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store)) | 870 | seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store)) | ||
| 871 | incarnation := domain.IncarnationID{1} | 871 | incarnation := domain.IncarnationID{1} | ||
| 872 | - | require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "work", IncarnationID: incarnation, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9, RecoveryState: domain.RecoveryFresh})) | 872 | + | require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "work", IncarnationID: incarnation, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9})) |
| 873 | 873 | ||||
| 874 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store)) | 874 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store)) | ||
| 875 | d.mu.Lock() | 875 | d.mu.Lock() | ||
| @@ -880,8 +880,7 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) { | |||||
| 880 | require.Equal(t, int64(7), stopped.createdAt) | 880 | require.Equal(t, int64(7), stopped.createdAt) | ||
| 881 | require.Equal(t, incarnation, stopped.incarnation) | 881 | require.Equal(t, incarnation, stopped.incarnation) | ||
| 882 | require.Equal(t, uint64(9), stopped.lastUsedSeq) | 882 | require.Equal(t, uint64(9), stopped.lastUsedSeq) | ||
| 883 | - | require.Equal(t, domain.RecoveryFresh, stopped.record.RecoveryState) | 883 | + | require.Equal(t, ports.SessionStopped, stopped.state) |
| 884 | - | require.Equal(t, runtimeFresh, stopped.state) | |||
| 885 | require.NotNil(t, stopped.restoreDone) | 884 | require.NotNil(t, stopped.restoreDone) | ||
| 886 | require.Equal(t, uint64(9), d.mruSeq.Load()) | 885 | require.Equal(t, uint64(9), d.mruSeq.Load()) | ||
| 887 | } | 886 | } | ||
| @@ -951,7 +950,7 @@ func TestTouchMRUPersistsNamedButNotEphemeral(t *testing.T) { | |||||
| 951 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 950 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| 952 | WithStore(t, store)(d) | 951 | WithStore(t, store)(d) | ||
| 953 | named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1, incarnation: domain.IncarnationID{1}} | 952 | named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1, incarnation: domain.IncarnationID{1}} | ||
| 954 | - | require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryFresh})) | 953 | + | require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1})) |
| 955 | 954 | ||||
| 956 | d.touchMRU(named) | 955 | d.touchMRU(named) | ||
| 957 | require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq) | 956 | require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq) | ||
| @@ -994,7 +993,6 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) { | |||||
| 994 | require.True(t, state.has("work")) | 993 | require.True(t, state.has("work")) | ||
| 995 | created := state.record(t, "work") | 994 | created := state.record(t, "work") | ||
| 996 | require.NotEqual(t, domain.IncarnationID{}, created.IncarnationID) | 995 | require.NotEqual(t, domain.IncarnationID{}, created.IncarnationID) | ||
| 997 | - | require.Equal(t, domain.RecoveryFresh, created.RecoveryState) | |||
| 998 | require.NoError(t, created.Validate()) | 996 | require.NoError(t, created.Validate()) | ||
| 999 | require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell")) | 997 | require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell")) | ||
| 1000 | require.NoError(t, d.createTab(sess, sz)) | 998 | require.NoError(t, d.createTab(sess, sz)) | ||
| @@ -1142,7 +1140,7 @@ func TestAttachRestoresPersistedTabNames(t *testing.T) { | |||||
| 1142 | store, _ := newMockStore(t) | 1140 | store, _ := newMockStore(t) | ||
| 1143 | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | 1141 | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | ||
| 1144 | WithStore(t, store)(d) | 1142 | WithStore(t, store)(d) | ||
| 1145 | - | require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}, RecoveryState: domain.RecoveryFresh})) | 1143 | + | require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}})) |
| 1146 | d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}} | 1144 | d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}} | ||
| 1147 | tr := portsmocks.NewMockTransport(t) | 1145 | tr := portsmocks.NewMockTransport(t) | ||
| 1148 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | 1146 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | ||
| @@ -1356,7 +1354,7 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) { | |||||
| 1356 | store, state := newMockStore(t) | 1354 | store, state := newMockStore(t) | ||
| 1357 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 1355 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| 1358 | WithStore(t, store)(d) | 1356 | WithStore(t, store)(d) | ||
| 1359 | - | require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryFresh})) | 1357 | + | require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1})) |
| 1360 | d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1} | 1358 | d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1} | ||
| 1361 | require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true})) | 1359 | require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true})) | ||
| 1362 | require.False(t, state.has("old")) | 1360 | require.False(t, state.has("old")) | ||
| @@ -1837,15 +1835,15 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) { | |||||
| 1837 | alphaRef := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}} | 1835 | alphaRef := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}} | ||
| 1838 | workRef := &domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}} | 1836 | workRef := &domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}} | ||
| 1839 | records := []domain.CatalogueRecord{ | 1837 | records := []domain.CatalogueRecord{ | ||
| 1840 | - | {Name: "alpha", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/alpha", CreatedAt: 7, LastUsedSeq: 11, TabNames: []string{"shell"}, RecoveryState: domain.RecoveryHealthy, Committed: alphaRef}, | 1838 | + | {Name: "alpha", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/alpha", CreatedAt: 7, LastUsedSeq: 11, TabNames: []string{"shell"}, Committed: alphaRef}, |
| 1841 | - | {Name: "work", IncarnationID: domain.IncarnationID{2}, Cwd: "/tmp/work", CreatedAt: 9, LastUsedSeq: 13, TabNames: []string{"editor", "logs"}, RecoveryState: domain.RecoveryDegraded, Committed: workRef, DegradedReason: "uncertain legacy checkpoint"}, | 1839 | + | {Name: "work", IncarnationID: domain.IncarnationID{2}, Cwd: "/tmp/work", CreatedAt: 9, LastUsedSeq: 13, TabNames: []string{"editor", "logs"}, Committed: workRef, DegradedReason: "uncertain legacy checkpoint"}, |
| 1842 | } | 1840 | } | ||
| 1843 | 1841 | ||||
| 1844 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(persist.New(nil), records)) | 1842 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(persist.New(nil), records)) | ||
| 1845 | 1843 | ||||
| 1846 | alpha := d.stopped["alpha"] | 1844 | alpha := d.stopped["alpha"] | ||
| 1847 | require.Equal(t, records[0], alpha.record) | 1845 | require.Equal(t, records[0], alpha.record) | ||
| 1848 | - | require.Equal(t, runtimeRestoring, alpha.state) | 1846 | + | require.Equal(t, ports.SessionStopped, alpha.state) |
| 1849 | require.NotNil(t, alpha.restoreDone) | 1847 | require.NotNil(t, alpha.restoreDone) | ||
| 1850 | select { | 1848 | select { | ||
| 1851 | case <-alpha.restoreDone: | 1849 | case <-alpha.restoreDone: | ||
| @@ -1854,7 +1852,7 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) { | |||||
| 1854 | } | 1852 | } | ||
| 1855 | work := d.stopped["work"] | 1853 | work := d.stopped["work"] | ||
| 1856 | require.Equal(t, records[1], work.record) | 1854 | require.Equal(t, records[1], work.record) | ||
| 1857 | - | require.Equal(t, runtimeDegraded, work.state) | 1855 | + | require.Equal(t, ports.SessionBroken, work.state) |
| 1858 | require.NotNil(t, work.restoreDone) | 1856 | require.NotNil(t, work.restoreDone) | ||
| 1859 | select { | 1857 | select { | ||
| 1860 | case <-work.restoreDone: | 1858 | case <-work.restoreDone: | ||
| @@ -1868,7 +1866,7 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) { | |||||
| 1868 | func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) { | 1866 | func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) { | ||
| 1869 | store, _ := newMockStore(t) | 1867 | store, _ := newMockStore(t) | ||
| 1870 | seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store)) | 1868 | seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store)) | ||
| 1871 | - | require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900, RecoveryState: domain.RecoveryFresh})) | 1869 | + | require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900})) |
| 1872 | 1870 | ||||
| 1873 | p, release := newBlockingPTY(t) | 1871 | p, release := newBlockingPTY(t) | ||
| 1874 | defer release() | 1872 | defer release() | ||
M internal/usecase/daemon/snapshot_restore.go +8 -3
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "math" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 11 | 12 | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 13 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 13 | 14 | "github.com/bnema/vev/pkg/vt" | |
| @@ -16,11 +17,15 @@ import ( | |||
| 16 | 17 | func (d *Daemon) closeRestoreDone() { | |
| 17 | 18 | d.mu.Lock() | |
| 18 | 19 | for name, entry := range d.stopped { | |
| 19 | - | if entry.state != runtimeRestoring { | |
| 20 | + | if entry.restoreDone == nil { | |
| 20 | 21 | continue | |
| 21 | 22 | } | |
| 22 | - | entry.state = runtimeDegraded | |
| 23 | - | entry.record.RecoveryState = domain.RecoveryDegraded | |
| 23 | + | select { | |
| 24 | + | case <-entry.restoreDone: | |
| 25 | + | continue | |
| 26 | + | default: | |
| 27 | + | } | |
| 28 | + | entry.state = ports.SessionBroken | |
| 24 | 29 | if entry.record.DegradedReason == "" { | |
| 25 | 30 | entry.record.DegradedReason = "restore unavailable" | |
| 26 | 31 | } | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "math" | 8 | "math" | ||
| 9 | 9 | ||||
| 10 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 11 | "github.com/bnema/vev/internal/usecase/layout" | 12 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 12 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 13 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 13 | "github.com/bnema/vev/pkg/vt" | 14 | "github.com/bnema/vev/pkg/vt" | ||
| @@ -16,11 +17,15 @@ import ( | |||||
| 16 | func (d *Daemon) closeRestoreDone() { | 17 | func (d *Daemon) closeRestoreDone() { | ||
| 17 | d.mu.Lock() | 18 | d.mu.Lock() | ||
| 18 | for name, entry := range d.stopped { | 19 | for name, entry := range d.stopped { | ||
| 19 | - | if entry.state != runtimeRestoring { | 20 | + | if entry.restoreDone == nil { |
| 20 | continue | 21 | continue | ||
| 21 | } | 22 | } | ||
| 22 | - | entry.state = runtimeDegraded | 23 | + | select { |
| 23 | - | entry.record.RecoveryState = domain.RecoveryDegraded | 24 | + | case <-entry.restoreDone: |
| 25 | + | continue | |||
| 26 | + | default: | |||
| 27 | + | } | |||
| 28 | + | entry.state = ports.SessionBroken | |||
| 24 | if entry.record.DegradedReason == "" { | 29 | if entry.record.DegradedReason == "" { | ||
| 25 | entry.record.DegradedReason = "restore unavailable" | 30 | entry.record.DegradedReason = "restore unavailable" | ||
| 26 | } | 31 | } | ||
M internal/usecase/daemon/snapshot_restore_incremental.go +16 -34
| @@ -18,23 +18,13 @@ const catalogueRestoreConcurrency = 8 | |||
| 18 | 18 | ||
| 19 | 19 | var errRetryableRestoreLoad = errors.New("snapshot: retryable checkpoint load") | |
| 20 | 20 | ||
| 21 | - | func initialRuntimeRecoveryState(record domain.CatalogueRecord) (runtimeRecoveryState, chan struct{}) { | |
| 21 | + | func initialSessionState(record domain.CatalogueRecord) (ports.SessionState, chan struct{}) { | |
| 22 | 22 | done := make(chan struct{}) | |
| 23 | - | var state runtimeRecoveryState | |
| 24 | - | switch record.RecoveryState { | |
| 25 | - | case domain.RecoveryFresh: | |
| 26 | - | state = runtimeFresh | |
| 23 | + | state := ports.SessionStopped | |
| 24 | + | if record.DegradedReason != "" { | |
| 25 | + | state = ports.SessionBroken | |
| 27 | 26 | close(done) | |
| 28 | - | case domain.RecoveryHealthy: | |
| 29 | - | state = runtimeRestoring | |
| 30 | - | case domain.RecoveryDegraded: | |
| 31 | - | state = runtimeDegraded | |
| 32 | - | close(done) | |
| 33 | - | case domain.RecoveryDeleting: | |
| 34 | - | state = runtimeDeleting | |
| 35 | - | close(done) | |
| 36 | - | default: | |
| 37 | - | state = runtimeDegraded | |
| 27 | + | } else if record.Committed == nil { | |
| 38 | 28 | close(done) | |
| 39 | 29 | } | |
| 40 | 30 | return state, done | |
| @@ -84,7 +74,7 @@ func (d *Daemon) ensureCatalogueRegistryEntry(record domain.CatalogueRecord) { | |||
| 84 | 74 | if entry, ok := d.stopped[record.Name]; ok && entry.restoreDone != nil { | |
| 85 | 75 | return | |
| 86 | 76 | } | |
| 87 | - | state, done := initialRuntimeRecoveryState(record) | |
| 77 | + | state, done := initialSessionState(record) | |
| 88 | 78 | d.stopped[record.Name] = stoppedSession{ | |
| 89 | 79 | name: record.Name, | |
| 90 | 80 | cwd: record.Cwd, | |
| @@ -117,7 +107,7 @@ func closeRuntimeRestoreDoneLocked(done chan struct{}) { | |||
| 117 | 107 | } | |
| 118 | 108 | } | |
| 119 | 109 | ||
| 120 | - | func (d *Daemon) setStoppedRecovery(record domain.CatalogueRecord, state runtimeRecoveryState) { | |
| 110 | + | func (d *Daemon) setStoppedRecovery(record domain.CatalogueRecord, state ports.SessionState) { | |
| 121 | 111 | d.mu.Lock() | |
| 122 | 112 | defer d.mu.Unlock() | |
| 123 | 113 | entry, ok := d.stopped[record.Name] | |
| @@ -144,7 +134,6 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e | |||
| 144 | 134 | d.log.Error("reading catalogue before degradation failed", "session", record.Name, "err", readErr) | |
| 145 | 135 | case ok && current.IncarnationID == record.IncarnationID: | |
| 146 | 136 | record = current | |
| 147 | - | record.RecoveryState = domain.RecoveryDegraded | |
| 148 | 137 | record.DegradedReason = "checkpoint validation failed" | |
| 149 | 138 | switch { | |
| 150 | 139 | case retryable: | |
| @@ -165,7 +154,7 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e | |||
| 165 | 154 | switch { | |
| 166 | 155 | case degraded: | |
| 167 | 156 | entry.record = record | |
| 168 | - | entry.state = runtimeDegraded | |
| 157 | + | entry.state = ports.SessionBroken | |
| 169 | 158 | entry.cwd = record.Cwd | |
| 170 | 159 | entry.createdAt = record.CreatedAt | |
| 171 | 160 | entry.incarnation = record.IncarnationID | |
| @@ -189,22 +178,15 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e | |||
| 189 | 178 | } | |
| 190 | 179 | ||
| 191 | 180 | func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecord) error { | |
| 192 | - | switch record.RecoveryState { | |
| 193 | - | case domain.RecoveryFresh: | |
| 194 | - | d.setStoppedRecovery(record, runtimeFresh) | |
| 195 | - | d.logSessionRestoreComplete(record, 0, false) | |
| 196 | - | return nil | |
| 197 | - | case domain.RecoveryDegraded: | |
| 198 | - | d.setStoppedRecovery(record, runtimeDegraded) | |
| 199 | - | d.logSessionDegraded(record, "persisted-degraded") | |
| 181 | + | if record.DegradedReason != "" { | |
| 182 | + | d.setStoppedRecovery(record, ports.SessionBroken) | |
| 183 | + | d.logSessionDegraded(record, "persisted-broken") | |
| 200 | 184 | return nil | |
| 201 | - | case domain.RecoveryDeleting: | |
| 202 | - | d.setStoppedRecovery(record, runtimeDeleting) | |
| 185 | + | } | |
| 186 | + | if record.Committed == nil { | |
| 187 | + | d.setStoppedRecovery(record, ports.SessionStopped) | |
| 188 | + | d.logSessionRestoreComplete(record, 0, false) | |
| 203 | 189 | return nil | |
| 204 | - | case domain.RecoveryHealthy: | |
| 205 | - | // Continue below. | |
| 206 | - | default: | |
| 207 | - | return errors.New("snapshot: invalid catalogue recovery state") | |
| 208 | 190 | } | |
| 209 | 191 | if d.snapshotRepository == nil { | |
| 210 | 192 | return errors.New("snapshot: repository unavailable") | |
| @@ -251,7 +233,7 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor | |||
| 251 | 233 | if err := d.restoreSession(ctx, selectedSnapshot, selectedGeneration.Generation, selected); err != nil { | |
| 252 | 234 | return err | |
| 253 | 235 | } | |
| 254 | - | d.setStoppedRecovery(record, runtimeHealthy) | |
| 236 | + | d.setStoppedRecovery(record, ports.SessionStopped) | |
| 255 | 237 | d.logSessionRestoreComplete(record, selected.Generation, false) | |
| 256 | 238 | return nil | |
| 257 | 239 | } | |
| @@ -18,23 +18,13 @@ const catalogueRestoreConcurrency = 8 | |||||
| 18 | 18 | ||||
| 19 | var errRetryableRestoreLoad = errors.New("snapshot: retryable checkpoint load") | 19 | var errRetryableRestoreLoad = errors.New("snapshot: retryable checkpoint load") | ||
| 20 | 20 | ||||
| 21 | - | func initialRuntimeRecoveryState(record domain.CatalogueRecord) (runtimeRecoveryState, chan struct{}) { | 21 | + | func initialSessionState(record domain.CatalogueRecord) (ports.SessionState, chan struct{}) { |
| 22 | done := make(chan struct{}) | 22 | done := make(chan struct{}) | ||
| 23 | - | var state runtimeRecoveryState | 23 | + | state := ports.SessionStopped |
| 24 | - | switch record.RecoveryState { | 24 | + | if record.DegradedReason != "" { |
| 25 | - | case domain.RecoveryFresh: | 25 | + | state = ports.SessionBroken |
| 26 | - | state = runtimeFresh | |||
| 27 | close(done) | 26 | close(done) | ||
| 28 | - | case domain.RecoveryHealthy: | 27 | + | } else if record.Committed == nil { |
| 29 | - | state = runtimeRestoring | |||
| 30 | - | case domain.RecoveryDegraded: | |||
| 31 | - | state = runtimeDegraded | |||
| 32 | - | close(done) | |||
| 33 | - | case domain.RecoveryDeleting: | |||
| 34 | - | state = runtimeDeleting | |||
| 35 | - | close(done) | |||
| 36 | - | default: | |||
| 37 | - | state = runtimeDegraded | |||
| 38 | close(done) | 28 | close(done) | ||
| 39 | } | 29 | } | ||
| 40 | return state, done | 30 | return state, done | ||
| @@ -84,7 +74,7 @@ func (d *Daemon) ensureCatalogueRegistryEntry(record domain.CatalogueRecord) { | |||||
| 84 | if entry, ok := d.stopped[record.Name]; ok && entry.restoreDone != nil { | 74 | if entry, ok := d.stopped[record.Name]; ok && entry.restoreDone != nil { | ||
| 85 | return | 75 | return | ||
| 86 | } | 76 | } | ||
| 87 | - | state, done := initialRuntimeRecoveryState(record) | 77 | + | state, done := initialSessionState(record) |
| 88 | d.stopped[record.Name] = stoppedSession{ | 78 | d.stopped[record.Name] = stoppedSession{ | ||
| 89 | name: record.Name, | 79 | name: record.Name, | ||
| 90 | cwd: record.Cwd, | 80 | cwd: record.Cwd, | ||
| @@ -117,7 +107,7 @@ func closeRuntimeRestoreDoneLocked(done chan struct{}) { | |||||
| 117 | } | 107 | } | ||
| 118 | } | 108 | } | ||
| 119 | 109 | ||||
| 120 | - | func (d *Daemon) setStoppedRecovery(record domain.CatalogueRecord, state runtimeRecoveryState) { | 110 | + | func (d *Daemon) setStoppedRecovery(record domain.CatalogueRecord, state ports.SessionState) { |
| 121 | d.mu.Lock() | 111 | d.mu.Lock() | ||
| 122 | defer d.mu.Unlock() | 112 | defer d.mu.Unlock() | ||
| 123 | entry, ok := d.stopped[record.Name] | 113 | entry, ok := d.stopped[record.Name] | ||
| @@ -144,7 +134,6 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e | |||||
| 144 | d.log.Error("reading catalogue before degradation failed", "session", record.Name, "err", readErr) | 134 | d.log.Error("reading catalogue before degradation failed", "session", record.Name, "err", readErr) | ||
| 145 | case ok && current.IncarnationID == record.IncarnationID: | 135 | case ok && current.IncarnationID == record.IncarnationID: | ||
| 146 | record = current | 136 | record = current | ||
| 147 | - | record.RecoveryState = domain.RecoveryDegraded | |||
| 148 | record.DegradedReason = "checkpoint validation failed" | 137 | record.DegradedReason = "checkpoint validation failed" | ||
| 149 | switch { | 138 | switch { | ||
| 150 | case retryable: | 139 | case retryable: | ||
| @@ -165,7 +154,7 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e | |||||
| 165 | switch { | 154 | switch { | ||
| 166 | case degraded: | 155 | case degraded: | ||
| 167 | entry.record = record | 156 | entry.record = record | ||
| 168 | - | entry.state = runtimeDegraded | 157 | + | entry.state = ports.SessionBroken |
| 169 | entry.cwd = record.Cwd | 158 | entry.cwd = record.Cwd | ||
| 170 | entry.createdAt = record.CreatedAt | 159 | entry.createdAt = record.CreatedAt | ||
| 171 | entry.incarnation = record.IncarnationID | 160 | entry.incarnation = record.IncarnationID | ||
| @@ -189,22 +178,15 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e | |||||
| 189 | } | 178 | } | ||
| 190 | 179 | ||||
| 191 | func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecord) error { | 180 | func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecord) error { | ||
| 192 | - | switch record.RecoveryState { | 181 | + | if record.DegradedReason != "" { |
| 193 | - | case domain.RecoveryFresh: | 182 | + | d.setStoppedRecovery(record, ports.SessionBroken) |
| 194 | - | d.setStoppedRecovery(record, runtimeFresh) | 183 | + | d.logSessionDegraded(record, "persisted-broken") |
| 195 | - | d.logSessionRestoreComplete(record, 0, false) | |||
| 196 | - | return nil | |||
| 197 | - | case domain.RecoveryDegraded: | |||
| 198 | - | d.setStoppedRecovery(record, runtimeDegraded) | |||
| 199 | - | d.logSessionDegraded(record, "persisted-degraded") | |||
| 200 | return nil | 184 | return nil | ||
| 201 | - | case domain.RecoveryDeleting: | 185 | + | } |
| 202 | - | d.setStoppedRecovery(record, runtimeDeleting) | 186 | + | if record.Committed == nil { |
| 187 | + | d.setStoppedRecovery(record, ports.SessionStopped) | |||
| 188 | + | d.logSessionRestoreComplete(record, 0, false) | |||
| 203 | return nil | 189 | return nil | ||
| 204 | - | case domain.RecoveryHealthy: | |||
| 205 | - | // Continue below. | |||
| 206 | - | default: | |||
| 207 | - | return errors.New("snapshot: invalid catalogue recovery state") | |||
| 208 | } | 190 | } | ||
| 209 | if d.snapshotRepository == nil { | 191 | if d.snapshotRepository == nil { | ||
| 210 | return errors.New("snapshot: repository unavailable") | 192 | return errors.New("snapshot: repository unavailable") | ||
| @@ -251,7 +233,7 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor | |||||
| 251 | if err := d.restoreSession(ctx, selectedSnapshot, selectedGeneration.Generation, selected); err != nil { | 233 | if err := d.restoreSession(ctx, selectedSnapshot, selectedGeneration.Generation, selected); err != nil { | ||
| 252 | return err | 234 | return err | ||
| 253 | } | 235 | } | ||
| 254 | - | d.setStoppedRecovery(record, runtimeHealthy) | 236 | + | d.setStoppedRecovery(record, ports.SessionStopped) |
| 255 | d.logSessionRestoreComplete(record, selected.Generation, false) | 237 | d.logSessionRestoreComplete(record, selected.Generation, false) | ||
| 256 | return nil | 238 | return nil | ||
| 257 | } | 239 | } | ||
M internal/usecase/daemon/snapshot_restore_test.go +2 -4
| @@ -219,7 +219,7 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||
| 219 | 219 | pty, release := newBlockingPTY(t) | |
| 220 | 220 | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 221 | 221 | checkpoint := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)} | |
| 222 | - | record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), RecoveryState: domain.RecoveryHealthy, Committed: &checkpoint} | |
| 222 | + | record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), Committed: &checkpoint} | |
| 223 | 223 | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) | |
| 224 | 224 | WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d) | |
| 225 | 225 | WithSnapshotRepository(repository)(d) | |
| @@ -265,8 +265,7 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | |||
| 265 | 265 | record := domain.CatalogueRecord{ | |
| 266 | 266 | Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", | |
| 267 | 267 | CreatedAt: int64(snapshot.CreatedAt), UpdatedAt: 81, LastUsedSeq: 17, | |
| 268 | - | TabNames: []string{"before"}, RecoveryState: domain.RecoveryHealthy, | |
| 269 | - | Committed: &committed, | |
| 268 | + | TabNames: []string{"before"}, Committed: &committed, | |
| 270 | 269 | } | |
| 271 | 270 | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) | |
| 272 | 271 | ||
| @@ -292,7 +291,6 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | |||
| 292 | 291 | require.NoError(t, err) | |
| 293 | 292 | require.True(t, ok) | |
| 294 | 293 | require.Equal(t, record.IncarnationID, updated.IncarnationID) | |
| 295 | - | require.Equal(t, record.RecoveryState, updated.RecoveryState) | |
| 296 | 294 | require.Equal(t, record.Committed, updated.Committed) | |
| 297 | 295 | require.Equal(t, record.DegradedReason, updated.DegradedReason) | |
| 298 | 296 | require.Equal(t, []string{"after"}, updated.TabNames) | |
| @@ -219,7 +219,7 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||||
| 219 | pty, release := newBlockingPTY(t) | 219 | pty, release := newBlockingPTY(t) | ||
| 220 | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | 220 | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | ||
| 221 | checkpoint := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)} | 221 | checkpoint := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)} | ||
| 222 | - | record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), RecoveryState: domain.RecoveryHealthy, Committed: &checkpoint} | 222 | + | record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), Committed: &checkpoint} |
| 223 | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) | 223 | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) | ||
| 224 | WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d) | 224 | WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d) | ||
| 225 | WithSnapshotRepository(repository)(d) | 225 | WithSnapshotRepository(repository)(d) | ||
| @@ -265,8 +265,7 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | |||||
| 265 | record := domain.CatalogueRecord{ | 265 | record := domain.CatalogueRecord{ | ||
| 266 | Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", | 266 | Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", | ||
| 267 | CreatedAt: int64(snapshot.CreatedAt), UpdatedAt: 81, LastUsedSeq: 17, | 267 | CreatedAt: int64(snapshot.CreatedAt), UpdatedAt: 81, LastUsedSeq: 17, | ||
| 268 | - | TabNames: []string{"before"}, RecoveryState: domain.RecoveryHealthy, | 268 | + | TabNames: []string{"before"}, Committed: &committed, |
| 269 | - | Committed: &committed, | |||
| 270 | } | 269 | } | ||
| 271 | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) | 270 | catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) | ||
| 272 | 271 | ||||
| @@ -292,7 +291,6 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | |||||
| 292 | require.NoError(t, err) | 291 | require.NoError(t, err) | ||
| 293 | require.True(t, ok) | 292 | require.True(t, ok) | ||
| 294 | require.Equal(t, record.IncarnationID, updated.IncarnationID) | 293 | require.Equal(t, record.IncarnationID, updated.IncarnationID) | ||
| 295 | - | require.Equal(t, record.RecoveryState, updated.RecoveryState) | |||
| 296 | require.Equal(t, record.Committed, updated.Committed) | 294 | require.Equal(t, record.Committed, updated.Committed) | ||
| 297 | require.Equal(t, record.DegradedReason, updated.DegradedReason) | 295 | require.Equal(t, record.DegradedReason, updated.DegradedReason) | ||
| 298 | require.Equal(t, []string{"after"}, updated.TabNames) | 296 | require.Equal(t, []string{"after"}, updated.TabNames) | ||
M internal/usecase/daemon/snapshot_test.go +0 -1
| @@ -46,7 +46,6 @@ func TestCheckpointCatalogueFailureKeepsCaptureRetryable(t *testing.T) { | |||
| 46 | 46 | record := domain.CatalogueRecord{ | |
| 47 | 47 | Name: "work", | |
| 48 | 48 | IncarnationID: domain.IncarnationID{1}, | |
| 49 | - | RecoveryState: domain.RecoveryFresh, | |
| 50 | 49 | } | |
| 51 | 50 | catalogue.EXPECT().Record("work").Return(record, true, nil).Once() | |
| 52 | 51 | repository.EXPECT().Publish(mock.Anything, mock.Anything).Return(nil).Once() | |
| @@ -46,7 +46,6 @@ func TestCheckpointCatalogueFailureKeepsCaptureRetryable(t *testing.T) { | |||||
| 46 | record := domain.CatalogueRecord{ | 46 | record := domain.CatalogueRecord{ | ||
| 47 | Name: "work", | 47 | Name: "work", | ||
| 48 | IncarnationID: domain.IncarnationID{1}, | 48 | IncarnationID: domain.IncarnationID{1}, | ||
| 49 | - | RecoveryState: domain.RecoveryFresh, | |||
| 50 | } | 49 | } | ||
| 51 | catalogue.EXPECT().Record("work").Return(record, true, nil).Once() | 50 | catalogue.EXPECT().Record("work").Return(record, true, nil).Once() | ||
| 52 | repository.EXPECT().Publish(mock.Anything, mock.Anything).Return(nil).Once() | 51 | repository.EXPECT().Publish(mock.Anything, mock.Anything).Return(nil).Once() | ||
M internal/usecase/recovery/checkpoint.go +0 -1
| @@ -43,7 +43,6 @@ func (c *Coordinator) PublishCheckpoint(ctx context.Context, name string, public | |||
| 43 | 43 | ||
| 44 | 44 | ref := domain.CheckpointRef{Generation: publication.Generation, ManifestDigest: sha256.Sum256(publication.Manifest)} | |
| 45 | 45 | next := record | |
| 46 | - | next.RecoveryState = domain.RecoveryHealthy | |
| 47 | 46 | next.Committed = copyCheckpointRef(&ref) | |
| 48 | 47 | next.DegradedReason = "" | |
| 49 | 48 | if err := next.Validate(); err != nil { | |
| @@ -43,7 +43,6 @@ func (c *Coordinator) PublishCheckpoint(ctx context.Context, name string, public | |||||
| 43 | 43 | ||||
| 44 | ref := domain.CheckpointRef{Generation: publication.Generation, ManifestDigest: sha256.Sum256(publication.Manifest)} | 44 | ref := domain.CheckpointRef{Generation: publication.Generation, ManifestDigest: sha256.Sum256(publication.Manifest)} | ||
| 45 | next := record | 45 | next := record | ||
| 46 | - | next.RecoveryState = domain.RecoveryHealthy | |||
| 47 | next.Committed = copyCheckpointRef(&ref) | 46 | next.Committed = copyCheckpointRef(&ref) | ||
| 48 | next.DegradedReason = "" | 47 | next.DegradedReason = "" | ||
| 49 | if err := next.Validate(); err != nil { | 48 | if err := next.Validate(); err != nil { | ||
M internal/usecase/recovery/checkpoint_test.go +0 -3
| @@ -93,7 +93,6 @@ func checkpointRecord() domain.CatalogueRecord { | |||
| 93 | 93 | return domain.CatalogueRecord{ | |
| 94 | 94 | Name: "work", | |
| 95 | 95 | IncarnationID: domain.IncarnationID{1}, | |
| 96 | - | RecoveryState: domain.RecoveryFresh, | |
| 97 | 96 | } | |
| 98 | 97 | } | |
| 99 | 98 | ||
| @@ -138,7 +137,6 @@ func TestCheckpointCommits(t *testing.T) { | |||
| 138 | 137 | t.Run(tc.name, func(t *testing.T) { | |
| 139 | 138 | record, catalogue, _ := runCheckpointCommit(t, tc.count) | |
| 140 | 139 | require.Equal(t, tc.wantCommitted, record.Committed.Generation) | |
| 141 | - | require.Equal(t, domain.RecoveryHealthy, record.RecoveryState) | |
| 142 | 140 | require.Empty(t, record.DegradedReason) | |
| 143 | 141 | require.Equal(t, tc.count, catalogue.replaces) | |
| 144 | 142 | }) | |
| @@ -148,7 +146,6 @@ func TestCheckpointCommits(t *testing.T) { | |||
| 148 | 146 | func TestCheckpointCommitPublishOrphan(t *testing.T) { | |
| 149 | 147 | prior := checkpointRecord() | |
| 150 | 148 | priorRef := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}} | |
| 151 | - | prior.RecoveryState = domain.RecoveryHealthy | |
| 152 | 149 | prior.Committed = &priorRef | |
| 153 | 150 | cause := errors.New("catalogue sync failed") | |
| 154 | 151 | catalogue := &checkpointCatalogue{record: prior, replaceErr: cause} | |
| @@ -93,7 +93,6 @@ func checkpointRecord() domain.CatalogueRecord { | |||||
| 93 | return domain.CatalogueRecord{ | 93 | return domain.CatalogueRecord{ | ||
| 94 | Name: "work", | 94 | Name: "work", | ||
| 95 | IncarnationID: domain.IncarnationID{1}, | 95 | IncarnationID: domain.IncarnationID{1}, | ||
| 96 | - | RecoveryState: domain.RecoveryFresh, | |||
| 97 | } | 96 | } | ||
| 98 | } | 97 | } | ||
| 99 | 98 | ||||
| @@ -138,7 +137,6 @@ func TestCheckpointCommits(t *testing.T) { | |||||
| 138 | t.Run(tc.name, func(t *testing.T) { | 137 | t.Run(tc.name, func(t *testing.T) { | ||
| 139 | record, catalogue, _ := runCheckpointCommit(t, tc.count) | 138 | record, catalogue, _ := runCheckpointCommit(t, tc.count) | ||
| 140 | require.Equal(t, tc.wantCommitted, record.Committed.Generation) | 139 | require.Equal(t, tc.wantCommitted, record.Committed.Generation) | ||
| 141 | - | require.Equal(t, domain.RecoveryHealthy, record.RecoveryState) | |||
| 142 | require.Empty(t, record.DegradedReason) | 140 | require.Empty(t, record.DegradedReason) | ||
| 143 | require.Equal(t, tc.count, catalogue.replaces) | 141 | require.Equal(t, tc.count, catalogue.replaces) | ||
| 144 | }) | 142 | }) | ||
| @@ -148,7 +146,6 @@ func TestCheckpointCommits(t *testing.T) { | |||||
| 148 | func TestCheckpointCommitPublishOrphan(t *testing.T) { | 146 | func TestCheckpointCommitPublishOrphan(t *testing.T) { | ||
| 149 | prior := checkpointRecord() | 147 | prior := checkpointRecord() | ||
| 150 | priorRef := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}} | 148 | priorRef := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}} | ||
| 151 | - | prior.RecoveryState = domain.RecoveryHealthy | |||
| 152 | prior.Committed = &priorRef | 149 | prior.Committed = &priorRef | ||
| 153 | cause := errors.New("catalogue sync failed") | 150 | cause := errors.New("catalogue sync failed") | ||
| 154 | catalogue := &checkpointCatalogue{record: prior, replaceErr: cause} | 151 | catalogue := &checkpointCatalogue{record: prior, replaceErr: cause} | ||
M internal/usecase/recovery/coordinator.go +0 -1
| @@ -113,7 +113,6 @@ func (c *Coordinator) Create(ctx context.Context, record domain.CatalogueRecord) | |||
| 113 | 113 | return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err) | |
| 114 | 114 | } | |
| 115 | 115 | record.IncarnationID = id | |
| 116 | - | record.RecoveryState = domain.RecoveryFresh | |
| 117 | 116 | record.Committed = nil | |
| 118 | 117 | record.DegradedReason = "" | |
| 119 | 118 | if err := record.Validate(); err != nil { | |
| @@ -113,7 +113,6 @@ func (c *Coordinator) Create(ctx context.Context, record domain.CatalogueRecord) | |||||
| 113 | return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err) | 113 | return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err) | ||
| 114 | } | 114 | } | ||
| 115 | record.IncarnationID = id | 115 | record.IncarnationID = id | ||
| 116 | - | record.RecoveryState = domain.RecoveryFresh | |||
| 117 | record.Committed = nil | 116 | record.Committed = nil | ||
| 118 | record.DegradedReason = "" | 117 | record.DegradedReason = "" | ||
| 119 | if err := record.Validate(); err != nil { | 118 | if err := record.Validate(); err != nil { | ||
M internal/usecase/recovery/discard.go +4 -5
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | var ( | |
| 12 | 12 | ErrRecoveryRecordNotFound = errors.New("recovery: session not found") | |
| 13 | - | ErrSessionNotDegraded = errors.New("recovery: session is not degraded") | |
| 13 | + | ErrSessionNotBroken = errors.New("recovery: session is not broken") | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | // Discard throws away a broken session's persisted state and replaces it with a | |
| @@ -34,11 +34,11 @@ func (c *Coordinator) Discard(ctx context.Context, name string) error { | |||
| 34 | 34 | if !ok { | |
| 35 | 35 | return fmt.Errorf("discard %q: %w", name, ErrRecoveryRecordNotFound) | |
| 36 | 36 | } | |
| 37 | - | if record.RecoveryState == domain.RecoveryFresh { | |
| 37 | + | if record.Committed == nil { | |
| 38 | 38 | return nil | |
| 39 | 39 | } | |
| 40 | - | if record.RecoveryState != domain.RecoveryDegraded { | |
| 41 | - | return ErrSessionNotDegraded | |
| 40 | + | if record.DegradedReason == "" { | |
| 41 | + | return ErrSessionNotBroken | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | 44 | old := record.IncarnationID | |
| @@ -48,7 +48,6 @@ func (c *Coordinator) Discard(ctx context.Context, name string) error { | |||
| 48 | 48 | } | |
| 49 | 49 | fresh := record | |
| 50 | 50 | fresh.IncarnationID = next | |
| 51 | - | fresh.RecoveryState = domain.RecoveryFresh | |
| 52 | 51 | fresh.Committed = nil | |
| 53 | 52 | fresh.DegradedReason = "" | |
| 54 | 53 | if err := fresh.Validate(); err != nil { | |
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | var ( | 11 | var ( | ||
| 12 | ErrRecoveryRecordNotFound = errors.New("recovery: session not found") | 12 | ErrRecoveryRecordNotFound = errors.New("recovery: session not found") | ||
| 13 | - | ErrSessionNotDegraded = errors.New("recovery: session is not degraded") | 13 | + | ErrSessionNotBroken = errors.New("recovery: session is not broken") |
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | // Discard throws away a broken session's persisted state and replaces it with a | 16 | // Discard throws away a broken session's persisted state and replaces it with a | ||
| @@ -34,11 +34,11 @@ func (c *Coordinator) Discard(ctx context.Context, name string) error { | |||||
| 34 | if !ok { | 34 | if !ok { | ||
| 35 | return fmt.Errorf("discard %q: %w", name, ErrRecoveryRecordNotFound) | 35 | return fmt.Errorf("discard %q: %w", name, ErrRecoveryRecordNotFound) | ||
| 36 | } | 36 | } | ||
| 37 | - | if record.RecoveryState == domain.RecoveryFresh { | 37 | + | if record.Committed == nil { |
| 38 | return nil | 38 | return nil | ||
| 39 | } | 39 | } | ||
| 40 | - | if record.RecoveryState != domain.RecoveryDegraded { | 40 | + | if record.DegradedReason == "" { |
| 41 | - | return ErrSessionNotDegraded | 41 | + | return ErrSessionNotBroken |
| 42 | } | 42 | } | ||
| 43 | 43 | ||||
| 44 | old := record.IncarnationID | 44 | old := record.IncarnationID | ||
| @@ -48,7 +48,6 @@ func (c *Coordinator) Discard(ctx context.Context, name string) error { | |||||
| 48 | } | 48 | } | ||
| 49 | fresh := record | 49 | fresh := record | ||
| 50 | fresh.IncarnationID = next | 50 | fresh.IncarnationID = next | ||
| 51 | - | fresh.RecoveryState = domain.RecoveryFresh | |||
| 52 | fresh.Committed = nil | 51 | fresh.Committed = nil | ||
| 53 | fresh.DegradedReason = "" | 52 | fresh.DegradedReason = "" | ||
| 54 | if err := fresh.Validate(); err != nil { | 53 | if err := fresh.Validate(); err != nil { | ||
M internal/usecase/recovery/transactions_test.go +1 -3
| @@ -119,8 +119,7 @@ func (r *transactionRepository) DeleteIncarnation(_ context.Context, id domain.I | |||
| 119 | 119 | ||
| 120 | 120 | func degradedTransactionRecord() domain.CatalogueRecord { | |
| 121 | 121 | return domain.CatalogueRecord{ | |
| 122 | - | Name: "broken", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryDegraded, | |
| 123 | - | Committed: &domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}}, | |
| 122 | + | Name: "broken", IncarnationID: domain.IncarnationID{1}, Committed: &domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}}, | |
| 124 | 123 | DegradedReason: "checkpoint unreadable", | |
| 125 | 124 | } | |
| 126 | 125 | } | |
| @@ -157,7 +156,6 @@ func TestDiscardIsRetryIdempotent(t *testing.T) { | |||
| 157 | 156 | require.NoError(t, err) | |
| 158 | 157 | require.True(t, ok) | |
| 159 | 158 | require.Nil(t, got.Committed) | |
| 160 | - | require.Equal(t, domain.RecoveryFresh, got.RecoveryState) | |
| 161 | 159 | require.NotEqual(t, old.IncarnationID, got.IncarnationID) | |
| 162 | 160 | if tt.failBefore { | |
| 163 | 161 | require.Contains(t, repository.deletedIDs, old.IncarnationID) | |
| @@ -119,8 +119,7 @@ func (r *transactionRepository) DeleteIncarnation(_ context.Context, id domain.I | |||||
| 119 | 119 | ||||
| 120 | func degradedTransactionRecord() domain.CatalogueRecord { | 120 | func degradedTransactionRecord() domain.CatalogueRecord { | ||
| 121 | return domain.CatalogueRecord{ | 121 | return domain.CatalogueRecord{ | ||
| 122 | - | Name: "broken", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryDegraded, | 122 | + | Name: "broken", IncarnationID: domain.IncarnationID{1}, Committed: &domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}}, |
| 123 | - | Committed: &domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}}, | |||
| 124 | DegradedReason: "checkpoint unreadable", | 123 | DegradedReason: "checkpoint unreadable", | ||
| 125 | } | 124 | } | ||
| 126 | } | 125 | } | ||
| @@ -157,7 +156,6 @@ func TestDiscardIsRetryIdempotent(t *testing.T) { | |||||
| 157 | require.NoError(t, err) | 156 | require.NoError(t, err) | ||
| 158 | require.True(t, ok) | 157 | require.True(t, ok) | ||
| 159 | require.Nil(t, got.Committed) | 158 | require.Nil(t, got.Committed) | ||
| 160 | - | require.Equal(t, domain.RecoveryFresh, got.RecoveryState) | |||
| 161 | require.NotEqual(t, old.IncarnationID, got.IncarnationID) | 159 | require.NotEqual(t, old.IncarnationID, got.IncarnationID) | ||
| 162 | if tt.failBefore { | 160 | if tt.failBefore { | ||
| 163 | require.Contains(t, repository.deletedIDs, old.IncarnationID) | 161 | require.Contains(t, repository.deletedIDs, old.IncarnationID) | ||