detached 979dbfb

fix(recovery): harden durable session invariants

commit
979dbfb6387143dee313544090da2238ce9508b9
author
brice · · 2026-07-26 07:25 UTC
parent
a6f1d55
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
80 files · +2738 -1260
M internal/adapters/noticefile/noticefile.go +2 -7
@@ -311,13 +311,8 @@ func appendNoticeFragment(line, fragment []byte) ([]byte, bool) {
311311 }
312312 required := len(line) + len(fragment)
313313 if required > cap(line) {
314- capacity := cap(line) * 2
315- if capacity < noticeReadBufferSize {
316- capacity = noticeReadBufferSize
317- }
318- if capacity > maxNoticeRecordSize {
319- capacity = maxNoticeRecordSize
320- }
314+ capacity := max(cap(line)*2, noticeReadBufferSize)
315+ capacity = min(capacity, maxNoticeRecordSize)
321316 grown := make([]byte, len(line), capacity)
322317 copy(grown, line)
323318 line = grown
M internal/adapters/recoveryfs/journal.go +26 -12
@@ -104,16 +104,33 @@ func (j *Journal) ListDiscards(ctx context.Context) ([]domain.DiscardIntent, err
104104 if err != nil {
105105 return nil, err
106106 }
107- entries, readErr := dir.ReadDir(maxDiscardIntents + 1)
108- closeErr := dir.Close()
109- if readErr != nil && !errors.Is(readErr, io.EOF) {
110- return nil, readErr
111- }
112- if closeErr != nil {
113- return nil, closeErr
107+ entries := make([]os.DirEntry, 0, maxDiscardIntents)
108+ for {
109+ batch, readErr := dir.ReadDir(maxDiscardIntents + 1)
110+ for _, entry := range batch {
111+ if strings.HasPrefix(entry.Name(), ".intent-") && !entry.IsDir() {
112+ continue
113+ }
114+ if entry.IsDir() || !strings.HasPrefix(entry.Name(), "discard-") {
115+ _ = dir.Close()
116+ return nil, errors.New("recovery journal: invalid transaction entry")
117+ }
118+ entries = append(entries, entry)
119+ if len(entries) > maxDiscardIntents {
120+ _ = dir.Close()
121+ return nil, errors.New("recovery journal: transaction entry limit exceeded")
122+ }
123+ }
124+ if errors.Is(readErr, io.EOF) {
125+ break
126+ }
127+ if readErr != nil {
128+ _ = dir.Close()
129+ return nil, readErr
130+ }
114131 }
115- if len(entries) > maxDiscardIntents {
116- return nil, errors.New("recovery journal: transaction entry limit exceeded")
132+ if err := dir.Close(); err != nil {
133+ return nil, err
117134 }
118135 sort.Slice(entries, func(a, b int) bool { return entries[a].Name() < entries[b].Name() })
119136 out := make([]domain.DiscardIntent, 0, len(entries))
@@ -122,9 +139,6 @@ func (j *Journal) ListDiscards(ctx context.Context) ([]domain.DiscardIntent, err
122139 if err := ctx.Err(); err != nil {
123140 return nil, err
124141 }
125- if entry.IsDir() || !strings.HasPrefix(entry.Name(), "discard-") {
126- return nil, errors.New("recovery journal: invalid transaction entry")
127- }
128142 data, err := readFileBounded(filepath.Join(j.dir, entry.Name()), maxDiscardIntentSize)
129143 if err != nil {
130144 return nil, err
M internal/adapters/recoveryfs/journal_test.go +13 -0
@@ -38,6 +38,7 @@ func TestDiscardCrashMatrix(t *testing.T) {
3838 path := j.path(old)
3939 encoded, err := os.ReadFile(path)
4040 require.NoError(t, err)
41+ t.Cleanup(func() { require.NoError(t, os.WriteFile(path, encoded, 0o600)) })
4142 require.NoError(t, os.WriteFile(path, append(encoded, 0), 0o600))
4243 _, err = j.ListDiscards(ctx)
4344 require.Error(t, err)
@@ -77,6 +78,18 @@ func TestRecoveryJournalRoundTripAndIdempotentDelete(t *testing.T) {
7778 require.Empty(t, got)
7879 }
7980
81+func TestRecoveryJournalIgnoresAtomicLeftoversWhenCountingDiscards(t *testing.T) {
82+ j := New(t.TempDir())
83+ require.NoError(t, os.MkdirAll(j.dir, 0o700))
84+ for i := 0; i < maxDiscardIntents+1; i++ {
85+ require.NoError(t, os.WriteFile(filepath.Join(j.dir, ".intent-leftover-"+string(rune(0x1000+i))), nil, 0o600))
86+ }
87+
88+ got, err := j.ListDiscards(context.Background())
89+ require.NoError(t, err)
90+ require.Empty(t, got)
91+}
92+
8093 func TestRecoveryJournalRejectsMalformedIntent(t *testing.T) {
8194 j := New(t.TempDir())
8295 require.Error(t, j.SaveDiscard(context.Background(), domain.DiscardIntent{}))
M internal/adapters/snapshot/atomic.go +4 -3
@@ -13,15 +13,16 @@ import (
1313 )
1414
1515 func (r *Repository) ensureSession(id domain.IncarnationID) error {
16+ sessionPath := r.sessionPath(id)
1617 for _, directory := range []struct {
1718 path string
1819 phase string
1920 }{
2021 {r.dir, "repository"},
2122 {filepath.Join(r.dir, repositorySessionsDir), "sessions"},
22- {r.sessionPath(id), "session"},
23- {filepath.Join(r.sessionPath(id), repositoryObjectsDir), "objects"},
24- {filepath.Join(r.sessionPath(id), repositoryGenerations), "generations"},
23+ {sessionPath, "session"},
24+ {filepath.Join(sessionPath, repositoryObjectsDir), "objects"},
25+ {filepath.Join(sessionPath, repositoryGenerations), "generations"},
2526 } {
2627 if err := r.ensurePrivateDirectoryPhase(directory.path, directory.phase); err != nil {
2728 return fmt.Errorf("create snapshot repository directory: %w", err)
M internal/adapters/snapshot/legacy_load_test.go +0 -4
@@ -69,7 +69,6 @@ func (r *Repository) Load(ctx context.Context, name string) (ports.SnapshotGener
6969
7070 func (r *Repository) load(ctx context.Context, name, key string) (ports.SnapshotGeneration, error) {
7171 preferred, preferredDigest, err := r.readLegacyHead(key)
72- skipped := errors.Is(err, ErrInvalidHEAD)
7372 if err != nil && !errors.Is(err, os.ErrNotExist) && !errors.Is(err, ErrInvalidHEAD) {
7473 return ports.SnapshotGeneration{}, fmt.Errorf("read snapshot HEAD: %w", err)
7574 }
@@ -78,7 +77,6 @@ func (r *Repository) load(ctx context.Context, name, key string) (ports.Snapshot
7877 if err == nil && sha256.Sum256(got.Manifest) == preferredDigest {
7978 return got, nil
8079 }
81- skipped = true
8280 }
8381
8482 budget := maxDirectoryTraversalEntries
@@ -89,10 +87,8 @@ func (r *Repository) load(ctx context.Context, name, key string) (ports.Snapshot
8987 for _, generation := range candidates {
9088 got, err := r.loadGeneration(ctx, name, key, generation)
9189 if err != nil {
92- skipped = true
9390 continue
9491 }
95- _ = skipped
9692 return got, nil
9793 }
9894 return ports.SnapshotGeneration{}, fmt.Errorf("no complete snapshot generation for %q", name)
M internal/adapters/snapshot/load_bounds_test.go +10 -7
@@ -15,7 +15,7 @@ func TestRepositoryListStopsAtTraversalBudget(t *testing.T) {
1515 if err := os.MkdirAll(sessions, 0o700); err != nil {
1616 t.Fatal(err)
1717 }
18- for i := 0; i < maxDirectoryTraversalEntries+1; i++ {
18+ for i := range maxDirectoryTraversalEntries + 1 {
1919 if err := os.Mkdir(filepath.Join(sessions, fmt.Sprintf("hostile-%05d", i)), 0o700); err != nil {
2020 t.Fatal(err)
2121 }
@@ -34,7 +34,8 @@ func TestRepositoryLoadStopsAtGenerationTraversalBudget(t *testing.T) {
3434 if err := os.MkdirAll(generations, 0o700); err != nil {
3535 t.Fatal(err)
3636 }
37- for generation := uint64(1); generation <= uint64(maxDirectoryTraversalEntries+1); generation++ {
37+ for i := range maxDirectoryTraversalEntries + 1 {
38+ generation := uint64(i + 1)
3839 if err := os.WriteFile(filepath.Join(generations, generationFilename(generation)), nil, 0o600); err != nil {
3940 t.Fatal(err)
4041 }
@@ -48,8 +49,9 @@ func TestRepositoryLoadStopsAtGenerationTraversalBudget(t *testing.T) {
4849
4950 func TestRepositoryLoadFallsBackNewestToOldestWithoutGenerationEnumeration(t *testing.T) {
5051 repo := NewRepository(privateDir(t))
51- for generation := uint64(1); generation <= 3; generation++ {
52- if err := repo.Publish(context.Background(), repositoryPublicationAfter(t, repo, "named", generation, []byte(fmt.Sprintf("state-%d", generation)))); err != nil {
52+ for i := range 3 {
53+ generation := uint64(i + 1)
54+ if err := repo.Publish(context.Background(), repositoryPublicationAfter(t, repo, "named", generation, fmt.Appendf(nil, "state-%d", generation))); err != nil {
5355 t.Fatal(err)
5456 }
5557 }
@@ -70,12 +72,13 @@ func TestRepositoryLoadFallsBackNewestToOldestWithoutGenerationEnumeration(t *te
7072
7173 func TestRepositoryMaintainDoesNotCollectBeyondRetainedMetadataBudget(t *testing.T) {
7274 repo := NewRepository(privateDir(t))
73- for generation := uint64(1); generation <= uint64(maxMaintenanceMarkedGenerations+1); generation++ {
74- if err := repo.Publish(context.Background(), repositoryPublicationAfter(t, repo, "named", generation, []byte(fmt.Sprintf("state-%d", generation)))); err != nil {
75+ for i := range maxMaintenanceMarkedGenerations + 1 {
76+ generation := uint64(i + 1)
77+ if err := repo.Publish(context.Background(), repositoryPublicationAfter(t, repo, "named", generation, fmt.Appendf(nil, "state-%d", generation))); err != nil {
7578 t.Fatal(err)
7679 }
7780 }
78- for pass := 0; pass < maxMaintenanceMarkedGenerations*3; pass++ {
81+ for pass := range maxMaintenanceMarkedGenerations * 3 {
7982 if err := repo.Maintain(context.Background()); err != nil {
8083 t.Fatal(err)
8184 }
M internal/adapters/snapshot/maintenance_delete.go +1 -22
@@ -644,28 +644,7 @@ func (r *Repository) SaveQuarantineDescriptor(ctx context.Context, descriptor do
644644
645645 func quarantineDescriptorsEqual(a, b domain.QuarantineDescriptor) bool {
646646 return a.OldIncarnation == b.OldIncarnation && a.ReplacementIncarnation == b.ReplacementIncarnation &&
647- a.SessionName == b.SessionName && a.Reason == b.Reason && catalogueRecordsEqual(a.OldRecord, b.OldRecord)
648-}
649-
650-func catalogueRecordsEqual(a, b domain.CatalogueRecord) bool {
651- if a.Name != b.Name || a.IncarnationID != b.IncarnationID || a.Cwd != b.Cwd || a.CreatedAt != b.CreatedAt ||
652- a.UpdatedAt != b.UpdatedAt || a.LastUsedSeq != b.LastUsedSeq || a.RecoveryState != b.RecoveryState ||
653- a.DegradedReason != b.DegradedReason || len(a.TabNames) != len(b.TabNames) {
654- return false
655- }
656- for i := range a.TabNames {
657- if a.TabNames[i] != b.TabNames[i] {
658- return false
659- }
660- }
661- return checkpointRefsEqual(a.Committed, b.Committed) && checkpointRefsEqual(a.Fallbacks[0], b.Fallbacks[0]) && checkpointRefsEqual(a.Fallbacks[1], b.Fallbacks[1])
662-}
663-
664-func checkpointRefsEqual(a, b *domain.CheckpointRef) bool {
665- if a == nil || b == nil {
666- return a == nil && b == nil
667- }
668- return *a == *b
647+ a.SessionName == b.SessionName && a.Reason == b.Reason && a.OldRecord.Equal(b.OldRecord)
669648 }
670649
671650 func (r *Repository) QuarantineIncarnation(ctx context.Context, id domain.IncarnationID) error {
M internal/adapters/snapshot/maintenance_gc.go +88 -61
@@ -31,6 +31,37 @@ type manifestMaintenance struct {
3131 complete bool
3232 }
3333
34+type retentionEntryKind uint8
35+
36+const (
37+ retentionManifest retentionEntryKind = iota + 1
38+ retentionObject
39+)
40+
41+type retentionEntryPolicy struct {
42+ kind retentionEntryKind
43+ generations map[uint64]ports.SnapshotDigest
44+ objects map[ports.SnapshotDigest]struct{}
45+}
46+
47+func (p retentionEntryPolicy) delete(entry maintenanceDirEntry) bool {
48+ if entry.isDir {
49+ return false
50+ }
51+ switch p.kind {
52+ case retentionManifest:
53+ generation, canonical := parseGenerationFilename(entry.name)
54+ _, retained := p.generations[generation]
55+ return canonical && !retained
56+ case retentionObject:
57+ digest, canonical := parseObjectDigest(entry.name)
58+ _, retained := p.objects[digest]
59+ return canonical && !retained
60+ default:
61+ return false
62+ }
63+}
64+
3465 type sessionMaintenance struct {
3566 lock *sessionMutex
3667 token string
@@ -370,6 +401,44 @@ func keepSet(plan ports.RetentionPlan) map[uint64]ports.SnapshotDigest {
370401
371402 // MaintainSession incrementally validates and retains only catalogue-indexed
372403 // checkpoints. Every incarnation owns its budget and continuation state.
404+// processRetentionEntries charges stat-reported size for every entry, requeues
405+// the unadmitted suffix, and removes only canonical entries rejected by policy.
406+func (r *Repository) processRetentionEntries(ctx context.Context, dir, purpose string, entries []maintenanceDirEntry, budget *ports.MaintenanceBudget, consumedBefore bool, policy retentionEntryPolicy) (bool, error) {
407+ for i, entry := range entries {
408+ if err := ctx.Err(); err != nil {
409+ return false, err
410+ }
411+ path := filepath.Join(dir, entry.name)
412+ info, statErr := r.stat(path)
413+ if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
414+ return false, statErr
415+ }
416+ size := uint64(0)
417+ if statErr == nil && info.Size() > 0 {
418+ size = uint64(info.Size())
419+ }
420+ if budget.Entries == 0 || size > budget.Bytes {
421+ r.requeueMaintenanceEntries(dir, purpose, entries[i:])
422+ if !consumedBefore && i == 0 {
423+ return false, ErrMaintenanceBudgetTooSmall
424+ }
425+ return false, nil
426+ }
427+ budget.Entries--
428+ budget.Bytes -= size
429+ if !policy.delete(entry) {
430+ continue
431+ }
432+ if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
433+ return false, err
434+ }
435+ if err := r.syncDirectory(dir); err != nil {
436+ return false, err
437+ }
438+ }
439+ return true, nil
440+}
441+
373442 func (r *Repository) MaintainSession(ctx context.Context, plan ports.RetentionPlan, budget ports.MaintenanceBudget) (done bool, err error) {
374443 requested := budget
375444 if err := ctx.Err(); err != nil {
@@ -459,34 +528,14 @@ func (r *Repository) MaintainSession(ctx context.Context, plan ports.RetentionPl
459528 if err != nil {
460529 return false, err
461530 }
462- for i, entry := range entries {
463- path := r.manifestPath(plan.IncarnationID, 0)
464- path = filepath.Join(filepath.Dir(path), entry.name)
465- info, statErr := r.stat(path)
466- if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
467- return false, statErr
468- }
469- size := uint64(0)
470- if statErr == nil && info.Size() > 0 {
471- size = uint64(info.Size())
472- }
473- if budget.Entries == 0 || size > budget.Bytes {
474- r.requeueMaintenanceEntries(filepath.Dir(path), purpose, entries[i:])
475- return false, nil
476- }
477- budget.Entries--
478- budget.Bytes -= size
479- generation, canonical := parseGenerationFilename(entry.name)
480- if !entry.isDir && canonical {
481- if _, retained := keep[generation]; !retained {
482- if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
483- return false, err
484- }
485- if err := r.syncDirectory(filepath.Dir(path)); err != nil {
486- return false, err
487- }
488- }
489- }
531+ dir := filepath.Join(r.sessionPath(plan.IncarnationID), repositoryGenerations)
532+ consumedBefore := requested.Entries != budget.Entries || requested.Bytes != budget.Bytes
533+ admitted, err := r.processRetentionEntries(ctx, dir, purpose, entries, &budget, consumedBefore, retentionEntryPolicy{kind: retentionManifest, generations: keep})
534+ if err != nil {
535+ return false, err
536+ }
537+ if !admitted {
538+ return false, nil
490539 }
491540 state.manifestsDone = done
492541 if len(entries) == 0 && !done {
@@ -511,33 +560,13 @@ func (r *Repository) MaintainSession(ctx context.Context, plan ports.RetentionPl
511560 if err != nil {
512561 return false, err
513562 }
514- for i, entry := range entries {
515- path := filepath.Join(dir, entry.name)
516- info, statErr := r.stat(path)
517- if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
518- return false, statErr
519- }
520- size := uint64(0)
521- if statErr == nil && info.Size() > 0 {
522- size = uint64(info.Size())
523- }
524- if budget.Entries == 0 || size > budget.Bytes {
525- r.requeueMaintenanceEntries(dir, objectPurpose, entries[i:])
526- return false, nil
527- }
528- budget.Entries--
529- budget.Bytes -= size
530- digest, canonical := parseObjectDigest(entry.name)
531- if !entry.isDir && canonical {
532- if _, retained := state.references[digest]; !retained {
533- if err := r.remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
534- return false, err
535- }
536- if err := r.syncDirectory(dir); err != nil {
537- return false, err
538- }
539- }
540- }
563+ consumedBefore := requested.Entries != budget.Entries || requested.Bytes != budget.Bytes
564+ admitted, err := r.processRetentionEntries(ctx, dir, objectPurpose, entries, &budget, consumedBefore, retentionEntryPolicy{kind: retentionObject, objects: state.references})
565+ if err != nil {
566+ return false, err
567+ }
568+ if !admitted {
569+ return false, nil
541570 }
542571 if done {
543572 state.objectShard = ""
@@ -587,15 +616,15 @@ func retentionToken(plan ports.RetentionPlan) string {
587616 return b.String()
588617 }
589618
590-// validateMaintenanceCheckpoint admits every payload by stat-reported size
591-// before reading it. It validates directly instead of calling LoadCheckpoint,
592-// which would materialize a complete generation before maintenance could
593-// enforce its byte budget.
594619 type validatedMaintenanceCheckpoint struct {
595620 refs map[ports.SnapshotDigest]codec.ObjectRef
596621 parent *domain.CheckpointRef
597622 }
598623
624+// validateMaintenanceCheckpoint admits every payload by stat-reported size
625+// before reading it. It validates directly instead of calling LoadCheckpoint,
626+// which would materialize a complete generation before maintenance could
627+// enforce its byte budget.
599628 func (r *Repository) validateMaintenanceCheckpoint(ctx context.Context, id domain.IncarnationID, ref ports.CheckpointRef, byteBudget uint64) (validatedMaintenanceCheckpoint, uint64, bool, error) {
600629 manifestPath := r.manifestPath(id, ref.Generation)
601630 info, err := r.stat(manifestPath)
@@ -692,8 +721,6 @@ func (r *Repository) Reconcile(ctx context.Context, records []domain.CatalogueRe
692721 byIncarnation[record.IncarnationID] = record
693722 }
694723
695- r.maintenanceMu.Lock()
696- defer r.maintenanceMu.Unlock()
697724 root := filepath.Join(r.dir, repositorySessionsDir)
698725 state := &maintenanceCursor{offset: offset}
699726 findingCapacity := maintenanceBatch
M internal/adapters/snapshot/maintenance_test.go +88 -16
@@ -1,6 +1,7 @@
11 package snapshot
22
33 import (
4+ "cmp"
45 "context"
56 "crypto/sha256"
67 "encoding/binary"
@@ -11,8 +12,10 @@ import (
1112 "path/filepath"
1213 "slices"
1314 "strings"
15+ "sync"
1416 "syscall"
1517 "testing"
18+ "time"
1619 "unsafe"
1720
1821 "github.com/bnema/vev/internal/domain"
@@ -1043,21 +1046,17 @@ func TestRetentionSlots(t *testing.T) {
10431046 }
10441047
10451048 func TestUnresolvedDataPinned(t *testing.T) {
1046- for _, state := range []string{"degraded", "deleting", "migration", "transaction"} {
1047- t.Run(state, func(t *testing.T) {
1048- repo := NewRepository(privateDir(t))
1049- publications := publishMaintenanceGenerations(t, repo, state, 4)
1050- done, err := repo.MaintainSession(context.Background(), ports.RetentionPlan{IncarnationID: publications[0].IncarnationID, PinAll: true}, ports.MaintenanceBudget{Entries: 1, Bytes: 1})
1051- if err != nil {
1052- t.Fatal(err)
1053- }
1054- if !done {
1055- t.Fatal("pinned maintenance did not complete without traversing")
1056- }
1057- if got := remainingMaintenanceGenerations(t, repo, publications[0].IncarnationID); !slices.Equal(got, []uint64{4, 3, 2, 1}) {
1058- t.Fatalf("pinned generations = %v", got)
1059- }
1060- })
1049+ repo := NewRepository(privateDir(t))
1050+ publications := publishMaintenanceGenerations(t, repo, "unresolved", 4)
1051+ done, err := repo.MaintainSession(context.Background(), ports.RetentionPlan{IncarnationID: publications[0].IncarnationID, PinAll: true}, ports.MaintenanceBudget{Entries: 1, Bytes: 1})
1052+ if err != nil {
1053+ t.Fatal(err)
1054+ }
1055+ if !done {
1056+ t.Fatal("pinned maintenance did not complete without traversing")
1057+ }
1058+ if got := remainingMaintenanceGenerations(t, repo, publications[0].IncarnationID); !slices.Equal(got, []uint64{4, 3, 2, 1}) {
1059+ t.Fatalf("pinned generations = %v", got)
10611060 }
10621061 }
10631062
@@ -1094,6 +1093,79 @@ func TestMaintenanceBudgetAdmitsPayloadsBeforeRead(t *testing.T) {
10941093 }
10951094 }
10961095
1096+func TestMaintainSessionDistinguishesInitialOversizeFromResumableExhaustion(t *testing.T) {
1097+ repo := NewRepository(privateDir(t))
1098+ publications := publishMaintenanceGenerations(t, repo, "budget-semantics", 2)
1099+ plan := ports.RetentionPlan{IncarnationID: publications[0].IncarnationID}
1100+ firstInfo, err := os.Stat(repo.manifestPath(plan.IncarnationID, 1))
1101+ if err != nil {
1102+ t.Fatal(err)
1103+ }
1104+ secondInfo, err := os.Stat(repo.manifestPath(plan.IncarnationID, 2))
1105+ if err != nil {
1106+ t.Fatal(err)
1107+ }
1108+ firstSize, secondSize := uint64(firstInfo.Size()), uint64(secondInfo.Size())
1109+
1110+ done, err := repo.MaintainSession(context.Background(), plan, ports.MaintenanceBudget{Entries: 2, Bytes: min(firstSize, secondSize) - 1})
1111+ if done || !errors.Is(err, ErrMaintenanceBudgetTooSmall) {
1112+ t.Fatalf("initial oversized item: done=%v err=%v", done, err)
1113+ }
1114+
1115+ done, err = repo.MaintainSession(context.Background(), plan, ports.MaintenanceBudget{Entries: 2, Bytes: firstSize + secondSize - 1})
1116+ if err != nil || done {
1117+ t.Fatalf("mid-pass exhaustion must be resumable: done=%v err=%v", done, err)
1118+ }
1119+ done, err = repo.MaintainSession(context.Background(), plan, ports.MaintenanceBudget{Entries: 64, Bytes: 8 << 20})
1120+ if err != nil || !done {
1121+ t.Fatalf("resumed maintenance: done=%v err=%v", done, err)
1122+ }
1123+}
1124+
1125+func TestReconcileDoesNotHoldMaintenanceLockDuringPayloadRead(t *testing.T) {
1126+ repo := NewRepository(privateDir(t))
1127+ publications := publishMaintenanceGenerations(t, repo, "reconcile-lock", 2)
1128+ committed := checkpointRefForPublication(publications[0])
1129+ record := domain.CatalogueRecord{Name: "reconcile-lock", IncarnationID: publications[0].IncarnationID, RecoveryState: domain.RecoveryHealthy, Committed: &committed}
1130+
1131+ entered := make(chan struct{})
1132+ release := make(chan struct{})
1133+ var releaseOnce sync.Once
1134+ defer releaseOnce.Do(func() { close(release) })
1135+ repo.hooks.beforeMaintenancePayloadRead = func(string) {
1136+ select {
1137+ case <-entered:
1138+ default:
1139+ close(entered)
1140+ }
1141+ <-release
1142+ }
1143+ reconcileDone := make(chan error, 1)
1144+ go func() {
1145+ _, _, err := repo.Reconcile(context.Background(), []domain.CatalogueRecord{record}, ports.ReconcileCursor{}, ports.MaintenanceBudget{Entries: 64, Bytes: 8 << 20})
1146+ reconcileDone <- err
1147+ }()
1148+ <-entered
1149+
1150+ pinDone := make(chan error, 1)
1151+ go func() {
1152+ _, err := repo.MaintainSession(context.Background(), ports.RetentionPlan{IncarnationID: record.IncarnationID, PinAll: true}, ports.MaintenanceBudget{Entries: 1, Bytes: 1})
1153+ pinDone <- err
1154+ }()
1155+ select {
1156+ case err := <-pinDone:
1157+ if err != nil {
1158+ t.Fatal(err)
1159+ }
1160+ case <-time.After(time.Second):
1161+ t.Fatal("maintenance lock remained held during reconciliation payload I/O")
1162+ }
1163+ releaseOnce.Do(func() { close(release) })
1164+ if err := <-reconcileDone; err != nil {
1165+ t.Fatal(err)
1166+ }
1167+}
1168+
10971169 func TestPerSessionBudgetIsolation(t *testing.T) {
10981170 repo := NewRepository(privateDir(t))
10991171 large := publishMaintenanceGenerations(t, repo, "large", 4)
@@ -1149,6 +1221,6 @@ func remainingMaintenanceGenerations(t *testing.T, repo *Repository, id domain.I
11491221 generations = append(generations, generation)
11501222 }
11511223 }
1152- slices.SortFunc(generations, func(a, b uint64) int { return int(b) - int(a) })
1224+ slices.SortFunc(generations, func(a, b uint64) int { return cmp.Compare(b, a) })
11531225 return generations
11541226 }
M internal/adapters/snapshot/migration_v0.go +6 -3
@@ -49,7 +49,7 @@ func decodeManifestV1(data []byte) (legacyManifestV1, error) {
4949 objects := make([]legacyObjectV1, 0)
5050 add := func(ref codec.ObjectRef) error {
5151 if _, ok := seen[ref.Digest]; ok {
52- return errors.New("snapshot: duplicate legacy object digest")
52+ return nil
5353 }
5454 seen[ref.Digest] = struct{}{}
5555 objects = append(objects, legacyObjectV1{Kind: uint8(ref.Kind), Digest: ref.Digest, Size: uint64(ref.Size)})
@@ -209,7 +209,7 @@ func uncertainLegacyError(operation string, cause error) error {
209209 if cause == nil {
210210 return fmt.Errorf("%w: %s", ports.ErrLegacySnapshotUncertain, operation)
211211 }
212- return fmt.Errorf("%w: %s: %v", ports.ErrLegacySnapshotUncertain, operation, cause)
212+ return fmt.Errorf("%w: %s: %w", ports.ErrLegacySnapshotUncertain, operation, cause)
213213 }
214214
215215 // MigrateV1Checkpoint is the migration-only admission path for an authoritative
@@ -244,7 +244,10 @@ func (r *Repository) MigrateV1Checkpoint(ctx context.Context, req ports.Snapshot
244244 if manifest.Name != req.LegacyName || manifest.Generation != req.LegacyRef.Generation {
245245 return domain.CheckpointRef{}, uncertainLegacyError("manifest identity mismatch", nil)
246246 }
247- key, _ := incarnationKey(req.IncarnationID)
247+ key, err := incarnationKey(req.IncarnationID)
248+ if err != nil {
249+ return domain.CheckpointRef{}, err
250+ }
248251 lock := r.lockSession(key)
249252 defer r.unlockSession(lock)
250253 if err := r.ensureSession(req.IncarnationID); err != nil {
M internal/adapters/snapshot/migration_v0_test.go +39 -3
@@ -4,6 +4,7 @@ import (
44 "context"
55 "crypto/sha256"
66 "encoding/binary"
7+ "errors"
78 "fmt"
89 "hash/crc32"
910 "os"
@@ -37,10 +38,45 @@ func TestLegacyManifestV1(t *testing.T) {
3738 require.NoError(t, err)
3839 require.Equal(t, "work", got.Name)
3940 require.Equal(t, uint64(7), got.Generation)
40- for _, invalid := range [][]byte{encoded[:5], append(append([]byte(nil), encoded...), 0)} {
41- _, err := decodeManifestV1(invalid)
42- require.Error(t, err)
41+ for size := range len(encoded) {
42+ _, err := decodeManifestV1(encoded[:size])
43+ require.Errorf(t, err, "prefix length %d", size)
4344 }
45+ _, err = decodeManifestV1(append(append([]byte(nil), encoded...), 0))
46+ require.Error(t, err)
47+}
48+
49+func TestLegacyManifestV1DeduplicatesObjectsPreservingFirst(t *testing.T) {
50+ chunk, err := codec.MarshalObject(codec.HistoryChunk, []byte("chunk"))
51+ require.NoError(t, err)
52+ tail, err := codec.MarshalObject(codec.HistoryTail, []byte("tail"))
53+ require.NoError(t, err)
54+ visible, err := codec.MarshalObject(codec.Visible, []byte("visible"))
55+ require.NoError(t, err)
56+ chunkRef := codec.ObjectRef{Kind: codec.HistoryChunk, Digest: chunk.Digest, Size: uint32(len(chunk.Data))}
57+ manifest := codec.Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "work", Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: []codec.ObjectRef{chunkRef, chunkRef}, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}}
58+ modern, err := codec.MarshalManifest(manifest)
59+ require.NoError(t, err)
60+ body := append([]byte(nil), modern[legacyManifestHeaderSize:]...)
61+ body = append(body[:8], body[25:]...)
62+ legacy := make([]byte, legacyManifestHeaderSize, legacyManifestHeaderSize+len(body))
63+ copy(legacy, "VEVM")
64+ binary.BigEndian.PutUint16(legacy[4:6], 1)
65+ binary.BigEndian.PutUint32(legacy[8:12], uint32(len(body)))
66+ binary.BigEndian.PutUint32(legacy[12:16], crc32.ChecksumIEEE(body))
67+ legacy = append(legacy, body...)
68+
69+ decoded, err := decodeManifestV1(legacy)
70+ require.NoError(t, err)
71+ require.Len(t, decoded.Objects, 3)
72+ require.Equal(t, chunk.Digest, decoded.Objects[0].Digest)
73+}
74+
75+func TestUncertainLegacyErrorWrapsSentinelAndCause(t *testing.T) {
76+ cause := errors.New("legacy cause")
77+ err := uncertainLegacyError("read", cause)
78+ require.ErrorIs(t, err, ports.ErrLegacySnapshotUncertain)
79+ require.ErrorIs(t, err, cause)
4480 }
4581
4682 func TestHasLegacyState(t *testing.T) {
M internal/adapters/snapshot/publish_efficiency_test.go +7 -8
@@ -120,11 +120,11 @@ func TestRepositoryPublishVerifiesNecessaryExistingObjectOnce(t *testing.T) {
120120 }
121121 second := repositoryPublicationAfter(t, repo, "named", 2, []byte("second"))
122122 newTail := second.Objects[0]
123- key := legacyIncarnationID(second.Name).String()
124- if err := os.MkdirAll(filepath.Dir(repo.legacyObjectPath(key, newTail.Digest)), 0o700); err != nil {
123+ path := repo.objectPath(second.IncarnationID, newTail.Digest)
124+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
125125 t.Fatal(err)
126126 }
127- if err := os.WriteFile(repo.legacyObjectPath(key, newTail.Digest), newTail.Data, 0o600); err != nil {
127+ if err := os.WriteFile(path, newTail.Data, 0o600); err != nil {
128128 t.Fatal(err)
129129 }
130130
@@ -178,7 +178,6 @@ func largeIncrementalPublications(t testing.TB, name string, count int) (ports.S
178178
179179 func seedCompletePublication(t testing.TB, repo *Repository, publication ports.SnapshotPublication) {
180180 t.Helper()
181- key := legacyIncarnationID(publication.Name).String()
182181 if err := repo.ensureSession(publication.IncarnationID); err != nil {
183182 t.Fatal(err)
184183 }
@@ -187,17 +186,17 @@ func seedCompletePublication(t testing.TB, repo *Repository, publication ports.S
187186 t.Fatal(err)
188187 }
189188 for _, object := range publication.Objects {
190- if err := os.MkdirAll(filepath.Dir(repo.legacyObjectPath(key, object.Digest)), 0o700); err != nil {
189+ if err := os.MkdirAll(filepath.Dir(repo.objectPath(publication.IncarnationID, object.Digest)), 0o700); err != nil {
191190 t.Fatal(err)
192191 }
193- if err := os.WriteFile(repo.legacyObjectPath(key, object.Digest), object.Data, 0o600); err != nil {
192+ if err := os.WriteFile(repo.objectPath(publication.IncarnationID, object.Digest), object.Data, 0o600); err != nil {
194193 t.Fatal(err)
195194 }
196195 }
197- if err := os.WriteFile(repo.legacyManifestPath(key, publication.Generation), publication.Manifest, 0o600); err != nil {
196+ if err := os.WriteFile(repo.manifestPath(publication.IncarnationID, publication.Generation), publication.Manifest, 0o600); err != nil {
198197 t.Fatal(err)
199198 }
200- if err := os.WriteFile(repo.legacyHeadPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest)), 0o600); err != nil {
199+ if err := os.WriteFile(repo.headPath(publication.IncarnationID), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest)), 0o600); err != nil {
201200 t.Fatal(err)
202201 }
203202 if refs := manifestRefs(manifest); refs == nil {
M internal/adapters/snapshot/reconciliation_test.go +2 -2
@@ -2,13 +2,13 @@ package snapshot
22
33 import (
44 "context"
5- "os"
65 "path/filepath"
76 "testing"
87
98 "github.com/bnema/vev/internal/domain"
109 "github.com/bnema/vev/internal/ports"
1110 codec "github.com/bnema/vev/internal/usecase/snapshot"
11+ "github.com/bnema/vev/pkg/safedir"
1212 "github.com/stretchr/testify/require"
1313 )
1414
@@ -108,7 +108,7 @@ func TestReconciliationCursor(t *testing.T) {
108108 sessions := filepath.Join(repo.dir, repositorySessionsDir)
109109 for i := byte(20); i < 26; i++ {
110110 id := domain.IncarnationID{i}
111- require.NoError(t, os.Mkdir(filepath.Join(sessions, id.String()), 0o700))
111+ require.NoError(t, safedir.EnsurePrivate(filepath.Join(sessions, id.String())))
112112 }
113113
114114 cursor := ports.ReconcileCursor{}
M internal/adapters/snapshot/recovery_crash_test.go +16 -7
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/vev/internal/persist"
1414 "github.com/bnema/vev/internal/ports"
1515 "github.com/bnema/vev/internal/usecase/recovery"
16+ "github.com/bnema/vev/pkg/safedir"
1617 "github.com/stretchr/testify/require"
1718 )
1819
@@ -57,7 +58,7 @@ func TestDeletionRestartsAtInternalQuarantineBoundaries(t *testing.T) {
5758 record := domain.CatalogueRecord{Name: "work", IncarnationID: oldID, RecoveryState: domain.RecoveryFresh}
5859 require.NoError(t, catalogue.Create(record))
5960 sessionPath := repository.sessionPath(oldID)
60- require.NoError(t, os.MkdirAll(sessionPath, 0o700))
61+ require.NoError(t, safedir.EnsurePrivate(sessionPath))
6162 require.NoError(t, os.WriteFile(filepath.Join(sessionPath, "durable"), []byte("old namespace"), 0o600))
6263 require.NoError(t, os.WriteFile(filepath.Join(h.snapshotDir, filenameForName(record.Name)), []byte("old legacy"), 0o600))
6364 tc.inject(repository)
@@ -69,7 +70,8 @@ func TestDeletionRestartsAtInternalQuarantineBoundaries(t *testing.T) {
6970 // Simulate a new process: none of the adapter or coordinator state survives.
7071 catalogue, repository, journal = h.open(t)
7172 require.NoError(t, recovery.NewCoordinator(catalogue, repository, journal, nil).Recover(ctx))
72- _, exists := catalogue.Record(record.Name)
73+ _, exists, err := catalogue.Record(record.Name)
74+ require.NoError(t, err)
7375 require.False(t, exists)
7476 require.NoDirExists(t, repository.sessionPath(oldID))
7577 require.Equal(t, []byte("old namespace"), mustReadCrashFile(t, filepath.Join(h.snapshotDir, "quarantine", oldID.String(), "snapshot", "durable")))
@@ -84,13 +86,14 @@ func TestDeletionRestartsAtInternalQuarantineBoundaries(t *testing.T) {
8486 require.NoError(t, err)
8587 require.Equal(t, newID, created.IncarnationID)
8688 newPath := repository.sessionPath(newID)
87- require.NoError(t, os.MkdirAll(newPath, 0o700))
89+ require.NoError(t, safedir.EnsurePrivate(newPath))
8890 require.NoError(t, os.WriteFile(filepath.Join(newPath, "durable"), []byte("new namespace"), 0o600))
8991 require.NoError(t, catalogue.Close())
9092
9193 catalogue, repository, journal = h.open(t)
9294 require.NoError(t, recovery.NewCoordinator(catalogue, repository, journal, nil).Recover(ctx))
93- got, exists := catalogue.Record(record.Name)
95+ got, exists, err := catalogue.Record(record.Name)
96+ require.NoError(t, err)
9497 require.True(t, exists)
9598 require.Equal(t, newID, got.IncarnationID)
9699 require.Equal(t, []byte("new namespace"), mustReadCrashFile(t, filepath.Join(repository.sessionPath(newID), "durable")))
@@ -114,20 +117,26 @@ func TestCreatePreCommitRestartLeavesNoAdoptableNamespace(t *testing.T) {
114117 interruptedID := domain.IncarnationID{3}
115118 _, err := recovery.NewCoordinator(rejectCreateCatalogue{Catalogue: catalogue}, repository, journal, bytes.NewReader(interruptedID[:])).Create(ctx, domain.CatalogueRecord{Name: "work"})
116119 require.ErrorIs(t, err, errDeletionBoundaryCrash)
117- require.Empty(t, catalogue.Records())
120+ records, err := catalogue.Records()
121+ require.NoError(t, err)
122+ require.Empty(t, records)
118123 require.NoDirExists(t, filepath.Join(h.snapshotDir, repositorySessionsDir))
119124 require.NoError(t, catalogue.Close())
120125
121126 catalogue, repository, journal = h.open(t)
122127 require.NoError(t, recovery.NewCoordinator(catalogue, repository, journal, nil).Recover(ctx))
123- require.Empty(t, catalogue.Records())
128+ records, err = catalogue.Records()
129+ require.NoError(t, err)
130+ require.Empty(t, records)
124131 require.NoDirExists(t, repository.sessionPath(interruptedID))
125132
126133 retryID := domain.IncarnationID{4}
127134 created, err := recovery.NewCoordinator(catalogue, repository, journal, bytes.NewReader(retryID[:])).Create(ctx, domain.CatalogueRecord{Name: "work"})
128135 require.NoError(t, err)
129136 require.Equal(t, retryID, created.IncarnationID)
130- require.Len(t, catalogue.Records(), 1)
137+ records, err = catalogue.Records()
138+ require.NoError(t, err)
139+ require.Len(t, records, 1)
131140 require.NoDirExists(t, repository.sessionPath(interruptedID))
132141 require.NoError(t, catalogue.Close())
133142 }
M internal/adapters/snapshot/recovery_observability_test.go +58 -20
@@ -14,25 +14,63 @@ import (
1414 )
1515
1616 func TestGCBudgetDiagnostic(t *testing.T) {
17- var buffer bytes.Buffer
18- log := slog.New(slog.NewJSONHandler(&buffer, nil))
19- repository := NewRepository(t.TempDir(), log)
20- id := domain.IncarnationID{1}
21- done, err := repository.MaintainSession(context.Background(), ports.RetentionPlan{
22- IncarnationID: id,
23- Keep: []ports.CheckpointRef{{Generation: 3}, {Generation: 2}, {Generation: 1}},
24- }, ports.MaintenanceBudget{})
25- require.NoError(t, err)
26- require.False(t, done)
17+ for _, tc := range []struct {
18+ name string
19+ arrange func(*testing.T, *Repository) ports.RetentionPlan
20+ budget ports.MaintenanceBudget
21+ wantDone bool
22+ wantExhausted bool
23+ wantConsumption bool
24+ }{
25+ {
26+ name: "exhausted",
27+ arrange: func(_ *testing.T, _ *Repository) ports.RetentionPlan {
28+ return ports.RetentionPlan{IncarnationID: domain.IncarnationID{1}, Keep: []ports.CheckpointRef{{Generation: 3}, {Generation: 2}, {Generation: 1}}}
29+ },
30+ wantExhausted: true,
31+ },
32+ {
33+ name: "sufficient",
34+ arrange: func(t *testing.T, repository *Repository) ports.RetentionPlan {
35+ publication := publishMaintenanceGenerations(t, repository, "diagnostic", 1)[0]
36+ return ports.RetentionPlan{IncarnationID: publication.IncarnationID}
37+ },
38+ budget: ports.MaintenanceBudget{Entries: 64, Bytes: 8 << 20},
39+ wantDone: true,
40+ wantConsumption: true,
41+ },
42+ } {
43+ t.Run(tc.name, func(t *testing.T) {
44+ var buffer bytes.Buffer
45+ log := slog.New(slog.NewJSONHandler(&buffer, nil))
46+ repository := NewRepository(privateDir(t), log)
47+ plan := tc.arrange(t, repository)
48+ buffer.Reset()
2749
28- scanner := bufio.NewScanner(bytes.NewReader(buffer.Bytes()))
29- require.True(t, scanner.Scan())
30- var entry map[string]any
31- require.NoError(t, json.Unmarshal(scanner.Bytes(), &entry))
32- require.NoError(t, scanner.Err())
33- require.Equal(t, "snapshot_maintenance_progress", entry["msg"])
34- require.Equal(t, id.String(), entry["incarnation"])
35- require.EqualValues(t, 3, entry["retained"])
36- require.Contains(t, entry, "cursor")
37- require.Equal(t, true, entry["budget_exhausted"])
50+ done, err := repository.MaintainSession(context.Background(), plan, tc.budget)
51+ require.NoError(t, err)
52+ require.Equal(t, tc.wantDone, done)
53+
54+ scanner := bufio.NewScanner(bytes.NewReader(buffer.Bytes()))
55+ require.True(t, scanner.Scan())
56+ var entry map[string]any
57+ require.NoError(t, json.Unmarshal(scanner.Bytes(), &entry))
58+ require.NoError(t, scanner.Err())
59+ require.Equal(t, "snapshot_maintenance_progress", entry["msg"])
60+ require.Equal(t, plan.IncarnationID.String(), entry["incarnation"])
61+ require.Contains(t, entry, "cursor")
62+ require.Equal(t, tc.wantExhausted, entry["budget_exhausted"])
63+ consumedEntries, entriesOK := entry["consumed_entries"].(float64)
64+ consumedBytes, bytesOK := entry["consumed_bytes"].(float64)
65+ require.True(t, entriesOK)
66+ require.True(t, bytesOK)
67+ if tc.wantConsumption {
68+ require.Positive(t, consumedEntries)
69+ require.Positive(t, consumedBytes)
70+ } else {
71+ require.Zero(t, consumedEntries)
72+ require.Zero(t, consumedBytes)
73+ }
74+ })
75+ }
3876 }
M internal/app/daemon_integration_test.go +27 -18
@@ -453,15 +453,13 @@ func TestMultipleClientsOneLifecycleOwner(t *testing.T) {
453453 errs := make(chan error, clients)
454454 var wg sync.WaitGroup
455455 for range clients {
456- wg.Add(1)
457- go func() {
458- defer wg.Done()
456+ wg.Go(func() {
459457 _, err := ensureDaemonWithLifecycle(context.Background(), dir, dial, func() error {
460458 spawns.Add(1)
461459 return nil
462460 }, cfg)
463461 errs <- err
464- }()
462+ })
465463 }
466464 require.Never(t, func() bool { return spawns.Load() != 0 }, 50*time.Millisecond, time.Millisecond)
467465 close(ready)
@@ -537,7 +535,7 @@ func TestLifecycleOwnershipOutlivesMaintenanceWriter(t *testing.T) {
537535 closed: make(chan struct{}),
538536 }
539537 repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots"))
540- shutdownClock := newLifecycleShutdownClock()
538+ shutdownClock := newLifecycleShutdownClock(t)
541539 reconciler := &lifecycleBlockingReconciler{
542540 entered: make(chan struct{}),
543541 release: make(chan struct{}),
@@ -564,7 +562,7 @@ func TestLifecycleOwnershipOutlivesMaintenanceWriter(t *testing.T) {
564562 pty.NewFactory(),
565563 shutdownClock,
566564 discardLog(),
567- daemon.WithSnapshotRepository(repository, nil),
565+ daemon.WithSnapshotRepository(repository),
568566 daemon.WithDurableMaintenance(catalogue, repository, reconciler, nil),
569567 daemon.WithCatalogue(catalogue, nil),
570568 )
@@ -679,7 +677,7 @@ func TestLifecycleOwnershipOutlivesRestorationWriter(t *testing.T) {
679677 releaseRestore := func() { releaseOnce.Do(func() { close(blocking.release) }) }
680678 t.Cleanup(releaseRestore)
681679
682- shutdownClock := newLifecycleShutdownClock()
680+ shutdownClock := newLifecycleShutdownClock(t)
683681 ctx, cancel := context.WithCancel(context.Background())
684682 t.Cleanup(cancel)
685683 listenerReady := make(chan struct{})
@@ -707,7 +705,7 @@ func TestLifecycleOwnershipOutlivesRestorationWriter(t *testing.T) {
707705 discardLog(),
708706 daemon.WithShell("/bin/cat", nil),
709707 daemon.WithCatalogue(opened.Catalogue, opened.Records),
710- daemon.WithSnapshotRepository(blocking, nil),
708+ daemon.WithSnapshotRepository(blocking),
711709 )
712710 return d.Serve(ctx, listener)
713711 })
@@ -764,7 +762,7 @@ func publishRestorableCheckpoint(t *testing.T, stateDir string, repository *snap
764762 discardLog(),
765763 daemon.WithShell("/bin/cat", nil),
766764 daemon.WithCatalogue(opened.Catalogue, opened.Records),
767- daemon.WithSnapshotRepository(repository, nil),
765+ daemon.WithSnapshotRepository(repository),
768766 daemon.WithRecoveryCoordinator(coordinator),
769767 )
770768 served := make(chan error, 1)
@@ -773,7 +771,7 @@ func publishRestorableCheckpoint(t *testing.T, stateDir string, repository *snap
773771 tr, _ := attach(t, runtimeDir, ports.IntentNew, name, domain.Size{Cols: 80, Rows: 24})
774772 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("checkpoint me\n")})}))
775773 require.Eventually(t, func() bool {
776- record, ok := opened.Catalogue.Record(name)
774+ record, ok, _ := opened.Catalogue.Record(name)
777775 return ok && record.RecoveryState == domain.RecoveryHealthy && record.Committed != nil
778776 }, 5*time.Second, 10*time.Millisecond, "session never committed a checkpoint")
779777 require.NoError(t, tr.Close())
@@ -826,7 +824,7 @@ func runBlockedSnapshotWriterShutdown(t *testing.T) blockedSnapshotWriterShutdow
826824 t.Helper()
827825 runtimeDir := filepath.Join(t.TempDir(), "runtime")
828826 stateDir := filepath.Join(t.TempDir(), "state")
829- shutdownClock := newLifecycleShutdownClock()
827+ shutdownClock := newLifecycleShutdownClock(t)
830828 repository := newLifecycleBlockingRepository()
831829 ctx, cancel := context.WithCancel(context.Background())
832830 t.Cleanup(cancel)
@@ -849,7 +847,7 @@ func runBlockedSnapshotWriterShutdown(t *testing.T) blockedSnapshotWriterShutdow
849847 shutdownClock,
850848 discardLog(),
851849 daemon.WithShell("/bin/cat", nil),
852- daemon.WithSnapshotRepository(repository, nil),
850+ daemon.WithSnapshotRepository(repository),
853851 )
854852 return d.Serve(ctx, observed)
855853 })
@@ -913,21 +911,28 @@ func (r *lifecycleBlockingRepository) Publish(context.Context, ports.SnapshotPub
913911 }
914912
915913 type lifecycleShutdownClock struct {
914+ t *testing.T
916915 base ports.Clock
917916 timers chan *lifecycleManualTimer
918917 }
919918
920-func newLifecycleShutdownClock() *lifecycleShutdownClock {
921- return &lifecycleShutdownClock{base: clock.New(), timers: make(chan *lifecycleManualTimer, 4)}
919+func newLifecycleShutdownClock(t *testing.T) *lifecycleShutdownClock {
920+ t.Helper()
921+ return &lifecycleShutdownClock{t: t, base: clock.New(), timers: make(chan *lifecycleManualTimer, 4)}
922922 }
923923
924924 func (c *lifecycleShutdownClock) Now() time.Time { return c.base.Now() }
925925 func (c *lifecycleShutdownClock) NewTimer(delay time.Duration) ports.Timer {
926- if delay != time.Second {
926+ if delay != daemon.SnapshotShutdownTimeout() {
927927 return c.base.NewTimer(delay)
928928 }
929- timer := &lifecycleManualTimer{ch: make(chan time.Time, 1)}
930- c.timers <- timer
929+ timer := &lifecycleManualTimer{t: c.t, ch: make(chan time.Time, 1)}
930+ select {
931+ case c.timers <- timer:
932+ default:
933+ c.t.Errorf("unexpected additional snapshot shutdown timer with delay %s", delay)
934+ timer.fire()
935+ }
931936 return timer
932937 }
933938
@@ -943,12 +948,16 @@ func (c *lifecycleShutdownClock) nextTimer(t *testing.T) *lifecycleManualTimer {
943948 }
944949
945950 type lifecycleManualTimer struct {
951+ t *testing.T
946952 ch chan time.Time
947953 stopped atomic.Bool
948954 }
949955
950956 func (t *lifecycleManualTimer) C() <-chan time.Time { return t.ch }
951-func (t *lifecycleManualTimer) Reset(time.Duration) bool {
957+func (t *lifecycleManualTimer) Reset(delay time.Duration) bool {
958+ if delay != daemon.SnapshotShutdownTimeout() {
959+ t.t.Errorf("snapshot shutdown timer reset with delay %s, want %s", delay, daemon.SnapshotShutdownTimeout())
960+ }
952961 t.stopped.Store(false)
953962 return false
954963 }
M internal/app/durable_crash_matrix_test.go +7 -7
@@ -132,7 +132,7 @@ func TestDurableTransactionCrashMatrix(t *testing.T) {
132132 require.NoError(t, catalogue.Close())
133133
134134 h.recoverTwice(t, func(catalogue *persist.Persister, _ *snapshot.Repository, _ *recoveryfs.Journal) {
135- record, exists := catalogue.Record("work")
135+ record, exists, _ := catalogue.Record("work")
136136 require.True(t, exists)
137137 require.Equal(t, domain.IncarnationID{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}, record.IncarnationID)
138138 require.Equal(t, domain.RecoveryFresh, record.RecoveryState)
@@ -160,7 +160,7 @@ func TestDurableTransactionCrashMatrix(t *testing.T) {
160160
161161 ref := domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(publication.Manifest)}
162162 h.recoverTwice(t, func(catalogue *persist.Persister, repository *snapshot.Repository, _ *recoveryfs.Journal) {
163- stored, exists := catalogue.Record(record.Name)
163+ stored, exists, _ := catalogue.Record(record.Name)
164164 require.True(t, exists)
165165 if boundaryName == "snapshot-publish" {
166166 require.Equal(t, domain.RecoveryFresh, stored.RecoveryState)
@@ -189,8 +189,8 @@ func TestDurableTransactionCrashMatrix(t *testing.T) {
189189 require.NoError(t, catalogue.Close())
190190
191191 h.recoverTwice(t, func(catalogue *persist.Persister, _ *snapshot.Repository, _ *recoveryfs.Journal) {
192- _, oldExists := catalogue.Record("old")
193- renamed, newExists := catalogue.Record("new")
192+ _, oldExists, _ := catalogue.Record("old")
193+ renamed, newExists, _ := catalogue.Record("new")
194194 require.False(t, oldExists)
195195 require.True(t, newExists)
196196 require.Equal(t, record.IncarnationID, renamed.IncarnationID)
@@ -228,7 +228,7 @@ func TestDurableTransactionCrashMatrix(t *testing.T) {
228228 require.NoError(t, catalogue.Close())
229229
230230 h.recoverTwice(t, func(catalogue *persist.Persister, repository *snapshot.Repository, _ *recoveryfs.Journal) {
231- _, exists := catalogue.Record(record.Name)
231+ _, exists, _ := catalogue.Record(record.Name)
232232 require.False(t, exists)
233233 assertNoTombstones(t, repository)
234234 require.NoDirExists(t, filepath.Join(h.snapshotDir, "sessions", record.IncarnationID.String()))
@@ -275,7 +275,7 @@ func TestDurableDiscardCrashMatrix(t *testing.T) {
275275 require.NoError(t, catalogue.Close())
276276
277277 h.recoverTwice(t, func(catalogue *persist.Persister, _ *snapshot.Repository, journal *recoveryfs.Journal) {
278- got, exists := catalogue.Record(old.Name)
278+ got, exists, _ := catalogue.Record(old.Name)
279279 require.True(t, exists)
280280 require.Equal(t, newID, got.IncarnationID)
281281 require.Equal(t, domain.RecoveryFresh, got.RecoveryState)
@@ -312,7 +312,7 @@ func TestDeletionRecoveryProtectsReusedNameFilesystemBytes(t *testing.T) {
312312 require.NoError(t, catalogue.Close())
313313
314314 h.recoverTwice(t, func(catalogue *persist.Persister, repository *snapshot.Repository, _ *recoveryfs.Journal) {
315- got, exists := catalogue.Record("work")
315+ got, exists, _ := catalogue.Record("work")
316316 require.True(t, exists)
317317 require.Equal(t, replacement, got)
318318 require.Equal(t, replacementBytes, readTree(t, replacementPath))
M internal/app/offline_kill_retry_test.go +39 -46
@@ -14,50 +14,43 @@ import (
1414 )
1515
1616 func TestRunKillOfflineUsesIncarnationDeletionProtocol(t *testing.T) {
17- stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
18- t.Setenv("XDG_STATE_HOME", stateRoot)
19- t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
20-
21- id := domain.IncarnationID{1}
22- p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
23- now := time.Now().UnixNano()
24- require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: id, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}))
25- require.NoError(t, p.Close())
26- source := filepath.Join(stateRoot, "vev", "snapshots", "sessions", id.String())
27- require.NoError(t, os.MkdirAll(source, 0o700))
28- require.NoError(t, os.WriteFile(filepath.Join(source, "sentinel"), []byte("durable"), 0o600))
29-
30- require.NoError(t, runKill(context.Background(), "named", false, false))
31- require.NoFileExists(t, filepath.Join(source, "sentinel"))
32- require.FileExists(t, filepath.Join(stateRoot, "vev", "snapshots", "quarantine", id.String(), "snapshot", "sentinel"))
33-}
34-
35-// TestRunKillOfflineFindsSessionWhenOnlyPrevCandidateExists covers the same
36-// bug class as persist.LoadCatalogueReadOnly's fresh-install regression: a
37-// crash mid-compaction can leave sessions.kv absent with only a .prev (or
38-// .next) candidate on disk. `vev kill <name>` must still find and delete the
39-// session rather than reporting "no such session" from a bare os.Stat
40-// precheck on the fixed sessions.kv path.
41-func TestRunKillOfflineFindsSessionWhenOnlyPrevCandidateExists(t *testing.T) {
42- stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
43- t.Setenv("XDG_STATE_HOME", stateRoot)
44- t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
45-
46- id := domain.IncarnationID{1}
47- p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
48- now := time.Now().UnixNano()
49- require.NoError(t, p.Save(persist.Record{Name: "crashed", IncarnationID: id, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}))
50- require.NoError(t, p.Close())
51- source := filepath.Join(stateRoot, "vev", "snapshots", "sessions", id.String())
52- require.NoError(t, os.MkdirAll(source, 0o700))
53- require.NoError(t, os.WriteFile(filepath.Join(source, "sentinel"), []byte("durable"), 0o600))
54-
55- // Simulate a crash mid-compaction: current renamed away to .prev, no
56- // sessions.kv on disk at all.
57- storePath := persist.StorePath(filepath.Join(stateRoot, "vev"))
58- require.NoError(t, os.Rename(storePath, storePath+".prev"))
59-
60- require.NoError(t, runKill(context.Background(), "crashed", false, false))
61- require.NoFileExists(t, filepath.Join(source, "sentinel"))
62- require.FileExists(t, filepath.Join(stateRoot, "vev", "snapshots", "quarantine", id.String(), "snapshot", "sentinel"))
17+ tests := []struct {
18+ name string
19+ crashHook func(*testing.T, string)
20+ }{
21+ {name: "normal"},
22+ {
23+ name: ".prev crash hook",
24+ crashHook: func(t *testing.T, stateDir string) {
25+ t.Helper()
26+ storePath := persist.StorePath(stateDir)
27+ require.NoError(t, os.Rename(storePath, storePath+".prev"))
28+ },
29+ },
30+ }
31+
32+ for _, tt := range tests {
33+ t.Run(tt.name, func(t *testing.T) {
34+ stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
35+ t.Setenv("XDG_STATE_HOME", stateRoot)
36+ t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
37+ stateDir := filepath.Join(stateRoot, "vev")
38+
39+ id := domain.IncarnationID{1}
40+ p := newTestPersister(t, stateDir)
41+ now := time.Now().UnixNano()
42+ require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: id, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}))
43+ require.NoError(t, p.Close())
44+ source := filepath.Join(stateDir, "snapshots", "sessions", id.String())
45+ require.NoError(t, os.MkdirAll(source, 0o700))
46+ require.NoError(t, os.WriteFile(filepath.Join(source, "sentinel"), []byte("durable"), 0o600))
47+ if tt.crashHook != nil {
48+ tt.crashHook(t, stateDir)
49+ }
50+
51+ require.NoError(t, runKill(context.Background(), "named", false, false))
52+ require.NoFileExists(t, filepath.Join(source, "sentinel"))
53+ require.FileExists(t, filepath.Join(stateDir, "snapshots", "quarantine", id.String(), "snapshot", "sentinel"))
54+ })
55+ }
6356 }
M internal/app/run.go +29 -8
@@ -583,18 +583,22 @@ type fencedRecoveryIdentity struct {
583583
584584 // fencedRecoveries resolves each conflict against the post-recovery catalogue
585585 // so a fenced session is reported with the incarnation it actually holds now.
586-func fencedRecoveries(conflicts []recovery.RecoveryConflict, catalogue ports.Catalogue) []fencedRecoveryIdentity {
586+func fencedRecoveries(conflicts []recovery.RecoveryConflict, catalogue ports.Catalogue) ([]fencedRecoveryIdentity, error) {
587587 fenced := make([]fencedRecoveryIdentity, 0, len(conflicts))
588588 for _, conflict := range conflicts {
589589 identity := fencedRecoveryIdentity{name: conflict.Session, kind: conflict.Kind, reasonCode: fencedRecoveryReasonCode(conflict.Err)}
590590 if catalogue != nil {
591- if record, ok := catalogue.Record(conflict.Session); ok {
591+ record, ok, err := catalogue.Record(conflict.Session)
592+ if err != nil {
593+ return nil, err
594+ }
595+ if ok {
592596 identity.id = record.IncarnationID
593597 }
594598 }
595599 fenced = append(fenced, identity)
596600 }
597- return fenced
601+ return fenced, nil
598602 }
599603
600604 func fencedRecoveryReasonCode(err error) string {
@@ -674,6 +678,13 @@ func runDaemonOwned(ctx context.Context) (retErr error) {
674678 return runDaemonOwnedWithLogger(ctx, log)
675679 }
676680
681+func recoverAuthoritativeCatalogue(ctx context.Context, coordinator *recovery.Coordinator, catalogue ports.Catalogue) ([]domain.CatalogueRecord, error) {
682+ if err := coordinator.Recover(ctx); err != nil {
683+ return nil, err
684+ }
685+ return catalogue.Records()
686+}
687+
677688 func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr error) {
678689 clk := clock.New()
679690 observer, observerCloser, err := newPerformanceTrace(clk)
@@ -710,7 +721,7 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
710721 daemonOpts = append(daemonOpts, daemon.WithBarScriptCommandRunner(shellcmd.New()))
711722 daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
712723 snapshotRepository := snapshotadapter.NewRepository(snapshotDir(), log)
713- daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository, snapshotRepository))
724+ daemonOpts = append(daemonOpts, daemon.WithSnapshotRepository(snapshotRepository))
714725 noticeStore := noticefile.New(platform.StateDir())
715726 daemonOpts = append(daemonOpts, daemon.WithNoticeStore(noticeStore))
716727 storePath := persist.StorePath(platform.StateDir())
@@ -736,13 +747,21 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
736747 return errors.Join(fmt.Errorf("vev: inspect durable session transactions: %w", err), opened.Catalogue.Close())
737748 }
738749 coordinator := recovery.NewCoordinator(opened.Catalogue, snapshotRepository, journal, rand.Reader)
739- if err := coordinator.Recover(ctx); err != nil {
740- return errors.Join(fmt.Errorf("vev: recover durable session transactions: %w", err), opened.Catalogue.Close())
750+ // Recovery may roll catalogue changes forward, so reload the authoritative
751+ // snapshot before constructing runtime reservations. A failed reload aborts
752+ // startup rather than treating durable sessions as absent.
753+ recoveredRecords, err := recoverAuthoritativeCatalogue(ctx, coordinator, opened.Catalogue)
754+ if err != nil {
755+ return errors.Join(fmt.Errorf("vev: recover and reload durable session transactions: %w", err), opened.Catalogue.Close())
741756 }
757+ opened.Records = recoveredRecords
742758 // Recovery fences a session instead of failing startup when only that
743759 // session's durable state is self-inconsistent. The daemon then comes up
744760 // looking healthy, so every fenced session must be reported and persisted.
745- fenced := fencedRecoveries(coordinator.Conflicts(), opened.Catalogue)
761+ fenced, err := fencedRecoveries(coordinator.Conflicts(), opened.Catalogue)
762+ if err != nil {
763+ return errors.Join(fmt.Errorf("vev: read fenced recovery identities: %w", err), opened.Catalogue.Close())
764+ }
746765 logFencedRecoveries(log, fenced)
747766 if err := persistTransactionRecoveryNotices(noticeStore, pendingRecoveries, clk.Now()); err != nil {
748767 log.Error("transaction_recovery_notice_failed", "err", err)
@@ -1558,7 +1577,9 @@ func runOfflineNamedKill(ctx context.Context, name string) (retErr error) {
15581577 }
15591578 defer func() { retErr = errors.Join(retErr, opened.Catalogue.Close()) }()
15601579
1561- if _, ok := opened.Catalogue.Record(name); !ok {
1580+ if _, ok, err := opened.Catalogue.Record(name); err != nil {
1581+ return fmt.Errorf("vev: reading stored session: %w", err)
1582+ } else if !ok {
15621583 return fmt.Errorf("vev: no such session: %s", name)
15631584 }
15641585 coordinator := recovery.NewCoordinator(opened.Catalogue, repository, recoveryfs.New(platform.StateDir()), rand.Reader)
M internal/app/run_test.go +52 -19
@@ -19,6 +19,7 @@ import (
1919 "time"
2020
2121 "github.com/bnema/vev/internal/adapters/lifecycle"
22+ "github.com/bnema/vev/internal/adapters/noticefile"
2223 "github.com/bnema/vev/internal/adapters/recoveryfs"
2324 "github.com/bnema/vev/internal/adapters/snapshot"
2425 "github.com/bnema/vev/internal/domain"
@@ -110,9 +111,6 @@ func TestRecoveryObservability(t *testing.T) {
110111 require.Equal(t, "clean", recovered["outcome"])
111112 require.NotContains(t, eventNames(entries), "interrupted_transaction_fenced")
112113
113- logBuffer.Reset()
114- log.Error("catalogue_validation_failed", "path", "/state/vev/sessions.kv", "reason_code", "corrupt")
115- require.NotContains(t, eventNames(decodeJSONLogs(t, logBuffer.Bytes())), "daemon_startup_complete")
116114 require.NotContains(t, fmt.Sprint(requireEvent(t, entries, "interrupted_transaction_recovery_complete")), "terminal contents")
117115 require.Equal(t, domain.NoticeSnapshotRestore, recoveryNotice.Code)
118116 require.Equal(t, domain.SessionID(domain.IncarnationID{9}.String()), recoveryNotice.SessionID)
@@ -122,6 +120,27 @@ func TestRecoveryObservability(t *testing.T) {
122120 // A session-scoped conflict no longer aborts startup, so the daemon comes up
123121 // looking healthy. Startup must therefore name the fenced session in the log,
124122 // persist a notice for it, and never report the recovery as clean.
123+func TestStartupReloadsCatalogueAfterRolledForwardDeletion(t *testing.T) {
124+ ctx := t.Context()
125+ stateDir := filepath.Join(t.TempDir(), "vev")
126+ catalogue := newTestPersister(t, stateDir)
127+ t.Cleanup(func() { _ = catalogue.Close() })
128+ repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots"))
129+ record := domain.CatalogueRecord{
130+ Name: "work", IncarnationID: domain.IncarnationID{9}, Cwd: t.TempDir(),
131+ CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryDeleting,
132+ }
133+ require.NoError(t, catalogue.Save(record))
134+ coordinator := recovery.NewCoordinator(catalogue, repository, recoveryfs.New(stateDir), rand.Reader)
135+
136+ records, err := recoverAuthoritativeCatalogue(ctx, coordinator, catalogue)
137+ require.NoError(t, err)
138+ require.Empty(t, records, "daemon startup must not retain the pre-recovery reservation")
139+ _, exists, err := catalogue.Record(record.Name)
140+ require.NoError(t, err)
141+ require.False(t, exists, "rolled-forward deletion must remain absent in authoritative state")
142+}
143+
125144 func TestFencedRecoveryObservability(t *testing.T) {
126145 ctx := context.Background()
127146 stateDir := filepath.Join(t.TempDir(), "vev")
@@ -139,7 +158,8 @@ func TestFencedRecoveryObservability(t *testing.T) {
139158
140159 coordinator := recovery.NewCoordinator(catalogue, repository, recoveryfs.New(stateDir), rand.Reader)
141160 require.NoError(t, coordinator.Recover(ctx), "a session-scoped conflict must not abort startup")
142- fenced := fencedRecoveries(coordinator.Conflicts(), catalogue)
161+ fenced, err := fencedRecoveries(coordinator.Conflicts(), catalogue)
162+ require.NoError(t, err)
143163 require.Equal(t, []fencedRecoveryIdentity{{
144164 name: live.Name, id: live.IncarnationID, kind: "deletion-tombstone", reasonCode: "deletion-tombstone-conflict",
145165 }}, fenced)
@@ -148,7 +168,9 @@ func TestFencedRecoveryObservability(t *testing.T) {
148168 log := slog.New(slog.NewJSONHandler(&logBuffer, nil))
149169 logFencedRecoveries(log, fenced)
150170 logTransactionRecovery(log, 1, len(fenced))
151- logStartupRecoveryCounts(log, catalogue.Records(), 0)
171+ records, err := catalogue.Records()
172+ require.NoError(t, err)
173+ logStartupRecoveryCounts(log, records, 0)
152174
153175 entries := decodeJSONLogs(t, logBuffer.Bytes())
154176 event := requireEvent(t, entries, "interrupted_transaction_fenced")
@@ -163,17 +185,22 @@ func TestFencedRecoveryObservability(t *testing.T) {
163185 require.Equal(t, "fenced", recovered["outcome"])
164186 require.Contains(t, eventNames(entries), "daemon_startup_complete")
165187
166- store := portsmocks.NewMockNoticeStore(t)
167- var notice domain.Notification
168- store.EXPECT().Append(mock.Anything).Run(func(n domain.Notification) { notice = n }).Return(nil).Once()
188+ store := noticefile.New(stateDir)
169189 require.NoError(t, persistFencedRecoveryNotices(store, fenced, time.Unix(1, 0)))
190+ reopened := noticefile.New(stateDir)
191+ pending, err := reopened.Claim()
192+ require.NoError(t, err)
193+ require.Len(t, pending, 1)
194+ notice := pending[0]
170195 require.Equal(t, domain.NoticeSnapshotRestore, notice.Code)
171196 require.Equal(t, domain.NoticeWarn, notice.Severity)
172197 require.Equal(t, domain.SessionID(live.IncarnationID.String()), notice.SessionID)
173198 require.Contains(t, notice.Details, "session="+live.Name)
174199 require.Contains(t, notice.Details, "reason_code=deletion-tombstone-conflict")
200+ require.NoError(t, reopened.Ack())
175201
176- degraded, ok := catalogue.Record(live.Name)
202+ degraded, ok, err := catalogue.Record(live.Name)
203+ require.NoError(t, err)
177204 require.True(t, ok)
178205 require.Equal(t, domain.RecoveryDegraded, degraded.RecoveryState)
179206 }
@@ -444,19 +471,25 @@ func TestLifecycleOwnershipPrecedesDaemonStartup(t *testing.T) {
444471 stateDir := filepath.Join(stateRoot, "vev")
445472 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
446473 t.Setenv("XDG_STATE_HOME", stateRoot)
447- ctx, cancel := context.WithCancel(context.Background())
448- defer cancel()
474+ ctx, cancel := context.WithCancel(t.Context())
475+ result := make(chan error, 1)
449476 go func() {
450- for {
451- if _, err := os.Stat(filepath.Join(runtimeDir, "daemon.sock")); err == nil {
452- cancel()
453- return
454- }
455- time.Sleep(time.Millisecond)
456- }
477+ result <- runWithLifecycleOwner(ctx, runtimeDir, stateDir, runDaemonOwned)
457478 }()
479+ t.Cleanup(func() {
480+ cancel()
481+ select {
482+ case err := <-result:
483+ require.NoError(t, err)
484+ case <-time.After(5 * time.Second):
485+ t.Error("daemon lifecycle did not stop after cancellation")
486+ }
487+ })
458488
459- require.NoError(t, runWithLifecycleOwner(ctx, runtimeDir, stateDir, runDaemonOwned))
489+ require.Eventually(t, func() bool {
490+ _, err := os.Stat(filepath.Join(runtimeDir, "daemon.sock"))
491+ return err == nil
492+ }, 5*time.Second, time.Millisecond, "daemon socket was not published")
460493 require.FileExists(t, persist.StorePath(stateDir))
461494 })
462495
M internal/app/spawn.go +45 -49
@@ -67,25 +67,29 @@ var defaultBackoff = backoffConfig{
6767 total: 5 * time.Second,
6868 }
6969
70-func waitForLifecycleAvailability(ctx context.Context, runtimeDir string, cfg backoffConfig) (lifecycleOwnership, error) {
70+func retryAttempts[T any](ctx context.Context, cfg backoffConfig, attempt func() (T, bool, error)) (T, error) {
7171 deadline := time.Now().Add(cfg.total)
7272 backoff := cfg.initial
7373 for {
7474 if err := ctx.Err(); err != nil {
75- return nil, err
75+ var zero T
76+ return zero, err
7677 }
77- owner, err := daemonLifecycleProbe.TryAcquire(runtimeDir)
78- if err == nil {
79- return owner, nil
78+ result, done, err := attempt()
79+ if err != nil {
80+ var zero T
81+ return zero, err
8082 }
81- if !errors.Is(err, lifecycle.ErrBusy) {
82- return nil, err
83+ if done {
84+ return result, nil
8385 }
8486 if !time.Now().Before(deadline) {
85- return nil, ErrDaemonUnreachable
87+ var zero T
88+ return zero, ErrDaemonUnreachable
8689 }
8790 if err := waitBackoff(ctx, backoff); err != nil {
88- return nil, err
91+ var zero T
92+ return zero, err
8993 }
9094 if backoff *= 2; backoff > cfg.max {
9195 backoff = cfg.max
@@ -93,33 +97,39 @@ func waitForLifecycleAvailability(ctx context.Context, runtimeDir string, cfg ba
9397 }
9498 }
9599
96-func waitForDaemonOrLifecycle(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (ports.Transport, lifecycleOwnership, error) {
97- deadline := time.Now().Add(cfg.total)
98- backoff := cfg.initial
99- for {
100- if err := ctx.Err(); err != nil {
101- return nil, nil, err
100+func waitForLifecycleAvailability(ctx context.Context, runtimeDir string, cfg backoffConfig) (lifecycleOwnership, error) {
101+ return retryAttempts(ctx, cfg, func() (lifecycleOwnership, bool, error) {
102+ owner, err := daemonLifecycleProbe.TryAcquire(runtimeDir)
103+ if err == nil {
104+ return owner, true, nil
105+ }
106+ if !errors.Is(err, lifecycle.ErrBusy) {
107+ return nil, false, err
102108 }
109+ return nil, false, nil
110+ })
111+}
112+
113+type daemonOrLifecycle struct {
114+ transport ports.Transport
115+ owner lifecycleOwnership
116+}
117+
118+func waitForDaemonOrLifecycle(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (ports.Transport, lifecycleOwnership, error) {
119+ result, err := retryAttempts(ctx, cfg, func() (daemonOrLifecycle, bool, error) {
103120 if transport, err := dial(ctx, dir); err == nil {
104- return transport, nil, nil
121+ return daemonOrLifecycle{transport: transport}, true, nil
105122 }
106123 owner, err := daemonLifecycleProbe.TryAcquire(dir)
107124 if err == nil {
108- return nil, owner, nil
125+ return daemonOrLifecycle{owner: owner}, true, nil
109126 }
110127 if !errors.Is(err, lifecycle.ErrBusy) {
111- return nil, nil, err
128+ return daemonOrLifecycle{}, false, err
112129 }
113- if !time.Now().Before(deadline) {
114- return nil, nil, ErrDaemonUnreachable
115- }
116- if err := waitBackoff(ctx, backoff); err != nil {
117- return nil, nil, err
118- }
119- if backoff *= 2; backoff > cfg.max {
120- backoff = cfg.max
121- }
122- }
130+ return daemonOrLifecycle{}, false, nil
131+ })
132+ return result.transport, result.owner, err
123133 }
124134
125135 func waitBackoff(ctx context.Context, duration time.Duration) error {
@@ -224,28 +234,14 @@ func acquireSpawnLock(dir string) (release func(), acquired bool, err error) {
224234 // retryDial dials repeatedly with exponential backoff until the daemon
225235 // answers, the context is cancelled, or the total budget is exhausted.
226236 func retryDial(ctx context.Context, dir string, dial dialFunc, cfg backoffConfig) (ports.Transport, error) {
227- deadline := time.Now().Add(cfg.total)
228- backoff := cfg.initial
229- for {
230- if err := ctx.Err(); err != nil {
231- return nil, err
232- }
233- if t, err := dial(ctx, dir); err == nil {
234- return t, nil
235- }
236- if !time.Now().Before(deadline) {
237- slog.Error("daemon did not become reachable before retry budget expired", "socket_dir", dir, "budget", cfg.total)
238- return nil, ErrDaemonUnreachable
239- }
240- select {
241- case <-ctx.Done():
242- return nil, ctx.Err()
243- case <-time.After(backoff):
244- }
245- if backoff *= 2; backoff > cfg.max {
246- backoff = cfg.max
247- }
237+ transport, err := retryAttempts(ctx, cfg, func() (ports.Transport, bool, error) {
238+ transport, err := dial(ctx, dir)
239+ return transport, err == nil, nil
240+ })
241+ if errors.Is(err, ErrDaemonUnreachable) {
242+ slog.Error("daemon did not become reachable before retry budget expired", "socket_dir", dir, "budget", cfg.total)
248243 }
244+ return transport, err
249245 }
250246
251247 // realDial is the production dialer.
M internal/app/spawn_test.go +15 -0
@@ -258,6 +258,21 @@ func TestAcquireSpawnLockTakesOverStaleLock(t *testing.T) {
258258 release()
259259 }
260260
261+func TestRetryAttemptsChecksOperationErrorBeforeDeadline(t *testing.T) {
262+ attemptErr := errors.New("attempt failed")
263+ calls := 0
264+ _, err := retryAttempts(context.Background(), backoffConfig{}, func() (struct{}, bool, error) {
265+ calls++
266+ return struct{}{}, false, attemptErr
267+ })
268+ if !errors.Is(err, attemptErr) {
269+ t.Fatalf("retryAttempts error = %v, want %v", err, attemptErr)
270+ }
271+ if calls != 1 {
272+ t.Fatalf("attempt called %d times, want 1", calls)
273+ }
274+}
275+
261276 func TestRetryDialSucceedsAfterTransientFailures(t *testing.T) {
262277 want := portsmocks.NewMockTransport(t)
263278 var calls atomic.Int32
M internal/domain/recovery.go +19 -0
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "io"
8+ "slices"
89 "strings"
910 )
1011
@@ -44,6 +45,24 @@ type CatalogueRecord struct {
4445
4546 // CatalogueMetadataUpdate changes mutable runtime metadata for one catalogue
4647 // incarnation without carrying authority-owned recovery or checkpoint fields.
48+// Equal reports canonical value equality between checkpoint references.
49+func (r *CheckpointRef) Equal(other *CheckpointRef) bool {
50+ if r == nil || other == nil {
51+ return r == nil && other == nil
52+ }
53+ return *r == *other
54+}
55+
56+// Equal reports canonical value equality, including ordered tab names and
57+// pointed-to checkpoint values rather than pointer identity.
58+func (r CatalogueRecord) Equal(other CatalogueRecord) bool {
59+ return r.Name == other.Name && r.IncarnationID == other.IncarnationID && r.Cwd == other.Cwd &&
60+ r.CreatedAt == other.CreatedAt && r.UpdatedAt == other.UpdatedAt && r.LastUsedSeq == other.LastUsedSeq &&
61+ slices.Equal(r.TabNames, other.TabNames) && r.RecoveryState == other.RecoveryState &&
62+ r.Committed.Equal(other.Committed) && r.Fallbacks[0].Equal(other.Fallbacks[0]) &&
63+ r.Fallbacks[1].Equal(other.Fallbacks[1]) && r.DegradedReason == other.DegradedReason
64+}
65+
4766 type CatalogueMetadataUpdate struct {
4867 Name string
4968 IncarnationID IncarnationID
M internal/domain/recovery_test.go +22 -1
@@ -22,7 +22,12 @@ func TestIncarnationIDText(t *testing.T) {
2222 var decoded IncarnationID
2323 require.NoError(t, decoded.UnmarshalText(text))
2424 require.Equal(t, id, decoded)
25- require.Error(t, decoded.UnmarshalText([]byte("AB")))
25+ require.Error(t, decoded.UnmarshalText([]byte("AB0000000000000000000000000000CD")))
26+
27+ _, err = (IncarnationID{}).MarshalText()
28+ require.Error(t, err)
29+ require.Error(t, decoded.UnmarshalText([]byte("00000000000000000000000000000000")))
30+ require.Equal(t, id, decoded, "rejected zero text must not mutate the receiver")
2631 }
2732
2833 func TestCatalogueRecordValidate(t *testing.T) {
@@ -50,6 +55,7 @@ func TestCatalogueRecordValidate(t *testing.T) {
5055 {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}},
5156 {name: "zero checkpoint digest", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7}}},
5257 {name: "second fallback without first", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, Fallbacks: [2]*CheckpointRef{nil, {Generation: 6, ManifestDigest: [32]byte{2}}}}},
58+ {name: "fallback newer than committed", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 6, ManifestDigest: [32]byte{1}}, Fallbacks: [2]*CheckpointRef{{Generation: 7, ManifestDigest: [32]byte{2}}}}},
5359 }
5460 for _, tt := range tests {
5561 t.Run(tt.name, func(t *testing.T) {
@@ -58,6 +64,21 @@ func TestCatalogueRecordValidate(t *testing.T) {
5864 }
5965 }
6066
67+func TestCatalogueRecordEqualUsesCheckpointValuesAndOrderedTabs(t *testing.T) {
68+ committed := CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}}
69+ left := CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, TabNames: []string{"shell", "logs"}, RecoveryState: RecoveryHealthy, Committed: &committed}
70+ right := left
71+ right.TabNames = append([]string(nil), left.TabNames...)
72+ copy := committed
73+ right.Committed = &copy
74+ require.True(t, left.Equal(right))
75+
76+ right.TabNames[0], right.TabNames[1] = right.TabNames[1], right.TabNames[0]
77+ require.False(t, left.Equal(right))
78+ require.True(t, (*CheckpointRef)(nil).Equal(nil))
79+ require.False(t, left.Committed.Equal(nil))
80+}
81+
6182 func TestDeletionTombstoneValidate(t *testing.T) {
6283 tests := []struct {
6384 name string
M internal/persist/catalogue_test.go +31 -3
@@ -40,11 +40,29 @@ func testCatalogueRecordRoundTrip(t *testing.T) {
4040 got, err := decodeRecordValue(record.Name, encoded)
4141 require.NoError(t, err)
4242 require.Equal(t, record, got)
43+ for prefix := range len(encoded) {
44+ _, err := decodeRecordValue(record.Name, encoded[:prefix])
45+ require.Error(t, err, "prefix length %d", prefix)
46+ }
4347 require.Error(t, func() error { _, err := decodeRecordValue(record.Name, append(encoded, 0)); return err }())
4448 })
4549 }
4650 }
4751
52+func TestDecodeRecordValueWrapsMalformedAndValidationErrors(t *testing.T) {
53+ encoded, err := encodeRecordValue(validRecord("work", 1))
54+ require.NoError(t, err)
55+ const recoveryStateOffset = 4 + 2 + 16 + 4 + 8 + 8 + 8 + 4
56+ encoded[recoveryStateOffset] = 99
57+
58+ _, err = decodeRecordValue("work", encoded)
59+ require.ErrorIs(t, err, errMalformedRecord)
60+ joined, ok := err.(interface{ Unwrap() []error })
61+ require.True(t, ok)
62+ require.Len(t, joined.Unwrap(), 2)
63+ require.ErrorContains(t, joined.Unwrap()[1], "invalid recovery state")
64+}
65+
4866 func testCatalogueDuplicateIncarnations(t *testing.T) {
4967 id := domain.IncarnationID{1}
5068 require.Error(t, validateUniqueIncarnations([]domain.CatalogueRecord{
@@ -110,7 +128,8 @@ func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) {
110128 next := domain.CatalogueRecord{Name: "work", IncarnationID: original.IncarnationID, Cwd: "/new", UpdatedAt: 22, LastUsedSeq: 23, TabNames: []string{"editor", "logs"}}
111129 update := next.MetadataUpdate()
112130 require.NoError(t, p.UpdateMetadata(update))
113- got, ok := p.Record("work")
131+ got, ok, err := p.Record("work")
132+ require.NoError(t, err)
114133 require.True(t, ok)
115134 expected := original
116135 expected.Cwd, expected.UpdatedAt, expected.LastUsedSeq, expected.TabNames = next.Cwd, next.UpdatedAt, next.LastUsedSeq, next.TabNames
@@ -121,14 +140,16 @@ func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) {
121140 Name: "work", IncarnationID: original.IncarnationID, LastUsedSeq: &lastUsedSeq,
122141 }))
123142 expected.LastUsedSeq = lastUsedSeq
124- partiallyUpdated, ok := p.Record("work")
143+ partiallyUpdated, ok, err := p.Record("work")
144+ require.NoError(t, err)
125145 require.True(t, ok)
126146 require.Equal(t, expected, partiallyUpdated, "a partial metadata update must not clear other mutable or authority-owned fields")
127147
128148 stale := update
129149 stale.IncarnationID = domain.IncarnationID{2}
130150 require.Error(t, p.UpdateMetadata(stale))
131- unchanged, ok := p.Record("work")
151+ unchanged, ok, err := p.Record("work")
152+ require.NoError(t, err)
132153 require.True(t, ok)
133154 require.Equal(t, expected, unchanged)
134155 }
@@ -201,6 +222,13 @@ func testCatalogueLoadReadOnlyRecoversCrashStates(t *testing.T) {
201222 writeCandidate(t, StorePath(dir)+".next", want)
202223 },
203224 },
225+ {
226+ // Crash before a new installation publishes .next as current.
227+ name: "next-only-recovers-from-next",
228+ write: func(t *testing.T, dir string, want domain.CatalogueRecord) {
229+ writeCandidate(t, StorePath(dir)+".next", want)
230+ },
231+ },
204232 {
205233 // Crash leaves only a predecessor behind; no current, no successor.
206234 name: "prev-only-recovers-from-prev",
M internal/persist/migration.go +21 -5
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "fmt"
88 "io"
9+ "log/slog"
910 "os"
1011 "path/filepath"
1112 "sort"
@@ -37,6 +38,8 @@ type OpenDeps struct {
3738 StateDir string
3839 Random io.Reader
3940 SnapshotMigration ports.SnapshotMigration
41+ // Logger receives full adapter causes; durable degraded reasons are sanitized.
42+ Logger *slog.Logger
4043 // Fault is a test-only crash seam called after named durable boundaries.
4144 Fault func(string) error
4245 }
@@ -65,6 +68,9 @@ const (
6568 catalogueFormatName = "durable-session-v1.format"
6669 migrationRecordMagic = "VEVJ"
6770 migrationRecordVersion uint16 = 1
71+
72+ migrationDegradedLegacySnapshotUncertain = "legacy_snapshot_uncertain"
73+ migrationDegradedReasonMaxBytes = 64
6874 )
6975
7076 func OpenOrMigrate(ctx context.Context, deps OpenDeps) (OpenResult, error) {
@@ -300,11 +306,12 @@ func resumeLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, erro
300306 if !errors.Is(err, ports.ErrLegacySnapshotUncertain) {
301307 return OpenResult{}, fmt.Errorf("persist: migrate legacy snapshot %q: %w", legacy.Name, err)
302308 }
309+ migrationLogger(deps).Error("legacy snapshot migration uncertain", "session", legacy.Name, "err", err)
303310 if ref.Generation != 0 && ref.ManifestDigest != ([32]byte{}) {
304311 legacyRef := ref
305312 record.Validated[legacy.Name] = &legacyRef
306313 }
307- record.Degraded[legacy.Name] = err.Error()
314+ record.Degraded[legacy.Name] = migrationDegradedLegacySnapshotUncertain
308315 }
309316 if err := writeMigrationRecord(intent, record); err != nil {
310317 return OpenResult{}, err
@@ -374,6 +381,13 @@ func resumeLegacyMigration(ctx context.Context, deps OpenDeps) (OpenResult, erro
374381 return OpenResult{Catalogue: catalogue, Records: opened, Migrated: true}, nil
375382 }
376383
384+func migrationLogger(deps OpenDeps) *slog.Logger {
385+ if deps.Logger != nil {
386+ return deps.Logger
387+ }
388+ return slog.Default()
389+}
390+
377391 func migrationBoundary(deps OpenDeps, name string) error {
378392 if deps.Fault != nil {
379393 return deps.Fault(name)
@@ -609,10 +623,12 @@ func catalogueRecordsEqual(a, b []domain.CatalogueRecord) bool {
609623 if len(a) != len(b) {
610624 return false
611625 }
612- sort.Slice(a, func(i, j int) bool { return a[i].Name < a[j].Name })
613- sort.Slice(b, func(i, j int) bool { return b[i].Name < b[j].Name })
614- for i := range a {
615- if a[i].Name != b[i].Name || a[i].IncarnationID != b[i].IncarnationID || a[i].RecoveryState != b[i].RecoveryState {
626+ left := append([]domain.CatalogueRecord(nil), a...)
627+ right := append([]domain.CatalogueRecord(nil), b...)
628+ sort.Slice(left, func(i, j int) bool { return left[i].Name < left[j].Name })
629+ sort.Slice(right, func(i, j int) bool { return right[i].Name < right[j].Name })
630+ for i := range left {
631+ if !left[i].Equal(right[i]) {
616632 return false
617633 }
618634 }
M internal/persist/migration_test.go +67 -15
@@ -1,24 +1,29 @@
11 package persist
22
33 import (
4+ "bytes"
45 "context"
56 "crypto/sha256"
67 "encoding/binary"
78 "errors"
89 "io"
10+ "log/slog"
911 "os"
1012 "path/filepath"
1113 "strconv"
14+ "strings"
1215 "testing"
1316
1417 "github.com/bnema/vev/internal/domain"
1518 "github.com/bnema/vev/internal/ports"
1619 "github.com/bnema/vev/pkg/kv"
20+ "github.com/bnema/vev/pkg/safedir"
1721 "github.com/stretchr/testify/require"
1822 )
1923
2024 type migrationStub struct {
2125 legacy bool
26+ probePaths []string
2227 probeErr error
2328 heads map[string]domain.CheckpointRef
2429 migrateErr map[string]error
@@ -26,7 +31,19 @@ type migrationStub struct {
2631 sources map[string]string
2732 }
2833
29-func (s *migrationStub) HasLegacyState(context.Context) (bool, error) { return s.legacy, s.probeErr }
34+func (s *migrationStub) HasLegacyState(context.Context) (bool, error) {
35+ if s.probeErr != nil || s.legacy {
36+ return s.legacy, s.probeErr
37+ }
38+ for _, path := range s.probePaths {
39+ if _, err := os.Stat(path); err == nil {
40+ return true, nil
41+ } else if !errors.Is(err, os.ErrNotExist) {
42+ return false, err
43+ }
44+ }
45+ return false, nil
46+}
3047 func (s *migrationStub) ReadLegacyHEAD(_ context.Context, name string) (domain.CheckpointRef, error) {
3148 if source := s.sources[name]; source != "" {
3249 if _, err := os.Stat(source); err != nil {
@@ -74,7 +91,7 @@ func encodeLegacyRecordV0(r legacyCatalogueRecordV0) []byte {
7491 func writeLegacyCatalogue(t *testing.T, dir string, records ...legacyCatalogueRecordV0) string {
7592 t.Helper()
7693 require.NoError(t, os.Chmod(dir, 0o700))
77- require.NoError(t, os.MkdirAll(dir, 0o700))
94+ require.NoError(t, safedir.EnsurePrivate(dir))
7895 store, err := kv.Open(StorePath(dir))
7996 require.NoError(t, err)
8097 for _, record := range records {
@@ -198,7 +215,7 @@ func TestMigrationResumeMatrix(t *testing.T) {
198215 }
199216 uncertain := recordByName(t, result.Records, "uncertain")
200217 require.Equal(t, domain.RecoveryDegraded, uncertain.RecoveryState)
201- require.Contains(t, uncertain.DegradedReason, "corrupt legacy manifest")
218+ require.Equal(t, migrationDegradedLegacySnapshotUncertain, uncertain.DegradedReason)
202219 require.NoError(t, result.Catalogue.Close())
203220
204221 reopened, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: io.LimitReader(&countingReader{}, 0), SnapshotMigration: restarted})
@@ -219,12 +236,18 @@ func TestMigrationPreservesUncertain(t *testing.T) {
219236 dir := t.TempDir()
220237 legacy := writeLegacyCatalogue(t, dir, legacyCatalogueRecordV0{Name: "broken", Cwd: "/tmp"})
221238 ref := domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
222- result, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: &migrationStub{heads: map[string]domain.CheckpointRef{"broken": ref}, migrateErr: map[string]error{"broken": errors.Join(ports.ErrLegacySnapshotUncertain, errors.New("invalid legacy manifest"))}}})
239+ adapterCause := strings.Repeat("sensitive-adapter-detail", 64)
240+ var logs bytes.Buffer
241+ logger := slog.New(slog.NewTextHandler(&logs, nil))
242+ result, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: &migrationStub{heads: map[string]domain.CheckpointRef{"broken": ref}, migrateErr: map[string]error{"broken": errors.Join(ports.ErrLegacySnapshotUncertain, errors.New(adapterCause))}}, Logger: logger})
223243 require.NoError(t, err)
224244 require.NoError(t, result.Catalogue.Close())
225245 require.FileExists(t, legacy)
226246 require.Equal(t, domain.RecoveryDegraded, result.Records[0].RecoveryState)
227- require.NotEmpty(t, result.Records[0].DegradedReason)
247+ require.Equal(t, migrationDegradedLegacySnapshotUncertain, result.Records[0].DegradedReason)
248+ require.LessOrEqual(t, len(result.Records[0].DegradedReason), migrationDegradedReasonMaxBytes)
249+ require.NotContains(t, result.Records[0].DegradedReason, adapterCause)
250+ require.Contains(t, logs.String(), adapterCause, "the full adapter cause must remain diagnosable")
228251
229252 failClosedDir := t.TempDir()
230253 writeLegacyCatalogue(t, failClosedDir, legacyCatalogueRecordV0{Name: "work", Cwd: "/tmp"})
@@ -240,23 +263,36 @@ func TestMigrationPreservesUncertain(t *testing.T) {
240263 func TestOpenOrMigrateLegacyProbe(t *testing.T) {
241264 probeErr := errors.New("probe uncertain")
242265 for _, tc := range []struct {
243- name string
244- stub *migrationStub
245- wantNew bool
246- wantErr error
266+ name string
267+ stub *migrationStub
268+ arrange func(t *testing.T, dir string, stub *migrationStub)
269+ wantNew bool
270+ wantLegacy bool
271+ wantErr error
247272 }{
248- {"proven absent", &migrationStub{}, true, nil},
249- {"legacy blob", &migrationStub{legacy: true}, false, nil},
250- {"incremental namespace", &migrationStub{legacy: true}, false, nil},
251- {"probe error", &migrationStub{probeErr: probeErr}, false, probeErr},
273+ {name: "proven absent", stub: &migrationStub{}, wantNew: true},
274+ {name: "legacy blob", stub: &migrationStub{}, wantLegacy: true, arrange: func(t *testing.T, dir string, stub *migrationStub) {
275+ path := filepath.Join(dir, "legacy.snapshot")
276+ require.NoError(t, os.WriteFile(path, []byte("legacy"), 0o600))
277+ stub.probePaths = []string{path}
278+ }},
279+ {name: "incremental namespace", stub: &migrationStub{}, wantLegacy: true, arrange: func(t *testing.T, dir string, stub *migrationStub) {
280+ path := filepath.Join(dir, "snapshots")
281+ require.NoError(t, safedir.EnsurePrivate(path))
282+ stub.probePaths = []string{path}
283+ }},
284+ {name: "probe error", stub: &migrationStub{probeErr: probeErr}, wantErr: probeErr},
252285 } {
253286 t.Run(tc.name, func(t *testing.T) {
254287 dir := t.TempDir()
255288 require.NoError(t, os.Chmod(dir, 0o700))
289+ if tc.arrange != nil {
290+ tc.arrange(t, dir, tc.stub)
291+ }
256292 result, err := OpenOrMigrate(context.Background(), OpenDeps{StateDir: dir, Random: &countingReader{}, SnapshotMigration: tc.stub})
257293 if tc.wantErr != nil {
258294 require.ErrorIs(t, err, tc.wantErr)
259- } else if tc.stub.legacy {
295+ } else if tc.wantLegacy {
260296 require.Error(t, err)
261297 } else {
262298 require.NoError(t, err)
@@ -270,7 +306,7 @@ func TestOpenOrMigrateLegacyProbe(t *testing.T) {
270306 require.NoError(t, reopened.Catalogue.Close())
271307 }
272308 }
273- if tc.stub.legacy || tc.wantErr != nil {
309+ if tc.wantLegacy || tc.wantErr != nil {
274310 _, statErr := os.Stat(StorePath(dir))
275311 require.ErrorIs(t, statErr, os.ErrNotExist)
276312 }
@@ -278,6 +314,22 @@ func TestOpenOrMigrateLegacyProbe(t *testing.T) {
278314 }
279315 }
280316
317+func TestCatalogueRecordsEqualIsCompleteAndNonMutating(t *testing.T) {
318+ committed := &domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}}
319+ left := []domain.CatalogueRecord{
320+ {Name: "z", IncarnationID: domain.IncarnationID{1}, Cwd: "/old", CreatedAt: 1, UpdatedAt: 2, LastUsedSeq: 3, TabNames: []string{"shell"}, RecoveryState: domain.RecoveryHealthy, Committed: committed},
321+ validRecord("a", 2),
322+ }
323+ right := []domain.CatalogueRecord{left[1], left[0]}
324+ leftOrder, rightOrder := left[0].Name, right[0].Name
325+ require.True(t, catalogueRecordsEqual(left, right))
326+ require.Equal(t, leftOrder, left[0].Name)
327+ require.Equal(t, rightOrder, right[0].Name)
328+
329+ right[1].Cwd = "/different"
330+ require.False(t, catalogueRecordsEqual(left, right), "installed catalogue validation must compare every record field")
331+}
332+
281333 func TestAtomicMigrationWriteRemovesTempAfterRenameFailure(t *testing.T) {
282334 dir := t.TempDir()
283335 target := filepath.Join(dir, "target")
M internal/persist/persist.go +13 -55
@@ -3,8 +3,6 @@ package persist
33 import (
44 "errors"
55 "fmt"
6- "log/slog"
7- "os"
86 "path/filepath"
97 "sort"
108 "sync"
@@ -23,25 +21,6 @@ func StorePath(dir string) string { return filepath.Join(dir, filename) }
2321 type Persister struct {
2422 store ports.Store
2523 mu sync.Mutex
26- log *slog.Logger
27-}
28-
29-// Option configures a Persister at construction time.
30-type Option func(*Persister)
31-
32-// WithLogger directs diagnostics for failures that ports.Catalogue has no
33-// error return for (see Records) to log instead of the default logger.
34-func WithLogger(log *slog.Logger) Option {
35- return func(p *Persister) { p.log = log }
36-}
37-
38-// logger returns a nil-safe logger, defaulting to slog.Default() when no
39-// logger was configured.
40-func (p *Persister) logger() *slog.Logger {
41- if p != nil && p.log != nil {
42- return p.log
43- }
44- return slog.Default()
4524 }
4625
4726 // KVStore adapts the reusable WAL implementation to the persistence port.
@@ -76,28 +55,15 @@ func Open(dir string) (*Persister, error) {
7655 p, _, err := openCurrentCatalogue(dir, false)
7756 return p, err
7857 }
79-func New(store ports.Store, opts ...Option) *Persister {
80- p := &Persister{store: store}
81- for _, opt := range opts {
82- opt(p)
83- }
84- return p
85-}
58+func New(store ports.Store) *Persister { return &Persister{store: store} }
8659
8760 func openCurrentCatalogue(dir string, createProvenEmpty bool) (*Persister, []domain.CatalogueRecord, error) {
8861 path := StorePath(dir)
89- absent := true
90- for _, candidate := range []string{path, path + ".next", path + ".prev"} {
91- _, err := os.Stat(candidate)
92- if err == nil {
93- absent = false
94- continue
95- }
96- if !errors.Is(err, os.ErrNotExist) {
97- return nil, nil, err
98- }
62+ present, err := catalogueCandidatesPresent(path)
63+ if err != nil {
64+ return nil, nil, err
9965 }
100- if absent && !createProvenEmpty {
66+ if !present && !createProvenEmpty {
10167 return nil, nil, fmt.Errorf("%w: no catalogue candidates", errPersistenceUnavailable)
10268 }
10369 store, err := OpenStore(path)
@@ -148,30 +114,22 @@ func (p *Persister) Create(record domain.CatalogueRecord) error {
148114 return p.applyLocked(map[string]*domain.CatalogueRecord{record.Name: &record})
149115 }
150116
151-// Records satisfies ports.Catalogue, whose signature has no error return. A
152-// load failure (unavailable store, or a malformed record surfaced by
153-// decodeAll) must not silently look like an empty catalogue, so it is logged
154-// here instead of being swallowed.
155-func (p *Persister) Records() []domain.CatalogueRecord {
156- records, err := p.LoadCatalogue()
157- if err != nil {
158- p.logger().Error("persist: loading catalogue records failed", "err", err)
159- return []domain.CatalogueRecord{}
160- }
161- return records
162-}
163-func (p *Persister) Record(name string) (domain.CatalogueRecord, bool) {
117+func (p *Persister) Records() ([]domain.CatalogueRecord, error) { return p.LoadCatalogue() }
118+func (p *Persister) Record(name string) (domain.CatalogueRecord, bool, error) {
164119 if p == nil || p.store == nil {
165- return domain.CatalogueRecord{}, false
120+ return domain.CatalogueRecord{}, false, errPersistenceUnavailable
166121 }
167122 p.mu.Lock()
168123 defer p.mu.Unlock()
169124 value, ok := p.store.Get([]byte(name))
170125 if !ok {
171- return domain.CatalogueRecord{}, false
126+ return domain.CatalogueRecord{}, false, nil
172127 }
173128 record, err := decodeRecordValue(name, value)
174- return record, err == nil
129+ if err != nil {
130+ return domain.CatalogueRecord{}, false, err
131+ }
132+ return record, true, nil
175133 }
176134 func (p *Persister) Apply(records map[string]*domain.CatalogueRecord) error {
177135 if p == nil {
M internal/persist/persist_test.go +22 -47
@@ -1,9 +1,7 @@
11 package persist
22
33 import (
4- "bytes"
54 "errors"
6- "log/slog"
75 "path/filepath"
86 "testing"
97
@@ -51,9 +49,17 @@ func TestCatalogueBatchSyncBehavior(t *testing.T) {
5149 p := New(store)
5250 one := validRecord("one", 1)
5351 require.NoError(t, p.Replace("one", one))
52+ require.Contains(t, state, "one")
5453 renamed := one
5554 renamed.Name = "renamed"
55+ renamed.Cwd = "/renamed"
5656 require.NoError(t, p.Rename("one", renamed))
57+ require.NotContains(t, state, "one")
58+ encoded, ok := state["renamed"]
59+ require.True(t, ok)
60+ got, err := decodeRecordValue("renamed", encoded)
61+ require.NoError(t, err)
62+ require.Equal(t, renamed, got)
5763 }
5864
5965 func TestCatalogueBatchFailureDoesNotSync(t *testing.T) {
@@ -80,7 +86,9 @@ func TestCatalogueNilPersisterIsNoOp(t *testing.T) {
8086 require.NoError(t, err)
8187 require.Empty(t, records)
8288 require.NoError(t, p.Close())
83- require.Empty(t, p.Records())
89+ got, err := p.Records()
90+ require.NoError(t, err)
91+ require.Empty(t, got)
8492 }
8593
8694 // TestCatalogueUnavailableStoreErrors covers a non-nil Persister with no
@@ -102,51 +110,18 @@ func TestCatalogueUnavailableStoreErrors(t *testing.T) {
102110 records, err := p.LoadAll()
103111 require.ErrorIs(t, err, errPersistenceUnavailable)
104112 require.Empty(t, records)
105- require.Empty(t, p.Records())
113+ got, err := p.Records()
114+ require.ErrorIs(t, err, errPersistenceUnavailable)
115+ require.Empty(t, got)
106116 require.NoError(t, p.Close())
107117 }
108118
109-// TestCatalogueRecordsMakesLoadFailureObservable covers Records(), which
110-// ports.Catalogue.Records() gives no error return for: a load failure must
111-// not silently look like an empty catalogue to every caller. Records()
112-// degrades to an empty slice but must emit the underlying error through its
113-// (nil-safe, optional) logger rather than swallowing it outright.
114-func TestCatalogueRecordsMakesLoadFailureObservable(t *testing.T) {
115- tests := []struct {
116- name string
117- build func(t *testing.T, log *slog.Logger) *Persister
118- }{
119- {
120- name: "unavailable-store",
121- build: func(t *testing.T, log *slog.Logger) *Persister {
122- return New(nil, WithLogger(log))
123- },
124- },
125- {
126- name: "malformed-record",
127- build: func(t *testing.T, log *slog.Logger) *Persister {
128- store := portsmocks.NewMockStore(t)
129- store.EXPECT().Range(mock.Anything).Run(func(fn func([]byte, []byte) bool) {
130- fn([]byte("bad"), []byte{0, 1})
131- })
132- return New(store, WithLogger(log))
133- },
134- },
135- }
136- for _, tt := range tests {
137- t.Run(tt.name, func(t *testing.T) {
138- var buf bytes.Buffer
139- p := tt.build(t, slog.New(slog.NewTextHandler(&buf, nil)))
140- require.Empty(t, p.Records())
141- require.Contains(t, buf.String(), "loading catalogue records failed")
142- })
143- }
144-}
145-
146-// TestCatalogueRecordsDefaultsToSlogDefaultLogger covers the nil-safe logger
147-// fallback: a Persister built without WithLogger must not panic when a load
148-// failure needs to be reported.
149-func TestCatalogueRecordsDefaultsToSlogDefaultLogger(t *testing.T) {
150- p := New(nil)
151- require.NotPanics(t, func() { require.Empty(t, p.Records()) })
119+func TestCatalogueRecordsPropagatesMalformedRecord(t *testing.T) {
120+ store := portsmocks.NewMockStore(t)
121+ store.EXPECT().Range(mock.Anything).Run(func(fn func([]byte, []byte) bool) {
122+ fn([]byte("bad"), []byte{0, 1})
123+ })
124+ records, err := New(store).Records()
125+ require.Error(t, err)
126+ require.Empty(t, records)
152127 }
M internal/persist/record.go +6 -4
@@ -26,7 +26,11 @@ func encodeRecordValue(record domain.CatalogueRecord) ([]byte, error) {
2626 buf = append(buf, catalogueMagic[:]...)
2727 buf = binary.BigEndian.AppendUint16(buf, catalogueRecordVersion)
2828 buf = append(buf, record.IncarnationID[:]...)
29- buf = appendString(buf, record.Cwd)
29+ var err error
30+ buf, err = appendCheckedString(buf, record.Cwd)
31+ if err != nil {
32+ return nil, err
33+ }
3034 buf = binary.BigEndian.AppendUint64(buf, uint64(record.CreatedAt))
3135 buf = binary.BigEndian.AppendUint64(buf, uint64(record.UpdatedAt))
3236 buf = binary.BigEndian.AppendUint64(buf, record.LastUsedSeq)
@@ -35,7 +39,6 @@ func encodeRecordValue(record domain.CatalogueRecord) ([]byte, error) {
3539 }
3640 buf = binary.BigEndian.AppendUint32(buf, uint32(len(record.TabNames)))
3741 for _, name := range record.TabNames {
38- var err error
3942 buf, err = appendCheckedString(buf, name)
4043 if err != nil {
4144 return nil, err
@@ -45,7 +48,6 @@ func encodeRecordValue(record domain.CatalogueRecord) ([]byte, error) {
4548 buf = appendRef(buf, record.Committed)
4649 buf = appendRef(buf, record.Fallbacks[0])
4750 buf = appendRef(buf, record.Fallbacks[1])
48- var err error
4951 buf, err = appendCheckedString(buf, record.DegradedReason)
5052 return buf, err
5153 }
@@ -118,7 +120,7 @@ func decodeRecordValue(name string, value []byte) (domain.CatalogueRecord, error
118120 return domain.CatalogueRecord{}, errMalformedRecord
119121 }
120122 if err := record.Validate(); err != nil {
121- return domain.CatalogueRecord{}, fmt.Errorf("%w: %v", errMalformedRecord, err)
123+ return domain.CatalogueRecord{}, errors.Join(errMalformedRecord, fmt.Errorf("persist: invalid catalogue record: %w", err))
122124 }
123125 return record, nil
124126 }
M internal/ports/mocks/recovery_mock.go +26 -11
@@ -1428,7 +1428,7 @@ func (_c *MockCatalogue_Delete_Call) RunAndReturn(run func(s string) error) *Moc
14281428 }
14291429
14301430 // Record provides a mock function for the type MockCatalogue
1431-func (_mock *MockCatalogue) Record(s string) (domain.CatalogueRecord, bool) {
1431+func (_mock *MockCatalogue) Record(s string) (domain.CatalogueRecord, bool, error) {
14321432 ret := _mock.Called(s)
14331433
14341434 if len(ret) == 0 {
@@ -1437,7 +1437,8 @@ func (_mock *MockCatalogue) Record(s string) (domain.CatalogueRecord, bool) {
14371437
14381438 var r0 domain.CatalogueRecord
14391439 var r1 bool
1440- if returnFunc, ok := ret.Get(0).(func(string) (domain.CatalogueRecord, bool)); ok {
1440+ var r2 error
1441+ if returnFunc, ok := ret.Get(0).(func(string) (domain.CatalogueRecord, bool, error)); ok {
14411442 return returnFunc(s)
14421443 }
14431444 if returnFunc, ok := ret.Get(0).(func(string) domain.CatalogueRecord); ok {
@@ -1450,7 +1451,12 @@ func (_mock *MockCatalogue) Record(s string) (domain.CatalogueRecord, bool) {
14501451 } else {
14511452 r1 = ret.Get(1).(bool)
14521453 }
1453- return r0, r1
1454+ if returnFunc, ok := ret.Get(2).(func(string) error); ok {
1455+ r2 = returnFunc(s)
1456+ } else {
1457+ r2 = ret.Error(2)
1458+ }
1459+ return r0, r1, r2
14541460 }
14551461
14561462 // MockCatalogue_Record_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Record'
@@ -1477,18 +1483,18 @@ func (_c *MockCatalogue_Record_Call) Run(run func(s string)) *MockCatalogue_Reco
14771483 return _c
14781484 }
14791485
1480-func (_c *MockCatalogue_Record_Call) Return(catalogueRecord domain.CatalogueRecord, b bool) *MockCatalogue_Record_Call {
1481- _c.Call.Return(catalogueRecord, b)
1486+func (_c *MockCatalogue_Record_Call) Return(catalogueRecord domain.CatalogueRecord, b bool, err error) *MockCatalogue_Record_Call {
1487+ _c.Call.Return(catalogueRecord, b, err)
14821488 return _c
14831489 }
14841490
1485-func (_c *MockCatalogue_Record_Call) RunAndReturn(run func(s string) (domain.CatalogueRecord, bool)) *MockCatalogue_Record_Call {
1491+func (_c *MockCatalogue_Record_Call) RunAndReturn(run func(s string) (domain.CatalogueRecord, bool, error)) *MockCatalogue_Record_Call {
14861492 _c.Call.Return(run)
14871493 return _c
14881494 }
14891495
14901496 // Records provides a mock function for the type MockCatalogue
1491-func (_mock *MockCatalogue) Records() []domain.CatalogueRecord {
1497+func (_mock *MockCatalogue) Records() ([]domain.CatalogueRecord, error) {
14921498 ret := _mock.Called()
14931499
14941500 if len(ret) == 0 {
@@ -1496,6 +1502,10 @@ func (_mock *MockCatalogue) Records() []domain.CatalogueRecord {
14961502 }
14971503
14981504 var r0 []domain.CatalogueRecord
1505+ var r1 error
1506+ if returnFunc, ok := ret.Get(0).(func() ([]domain.CatalogueRecord, error)); ok {
1507+ return returnFunc()
1508+ }
14991509 if returnFunc, ok := ret.Get(0).(func() []domain.CatalogueRecord); ok {
15001510 r0 = returnFunc()
15011511 } else {
@@ -1503,7 +1513,12 @@ func (_mock *MockCatalogue) Records() []domain.CatalogueRecord {
15031513 r0 = ret.Get(0).([]domain.CatalogueRecord)
15041514 }
15051515 }
1506- return r0
1516+ if returnFunc, ok := ret.Get(1).(func() error); ok {
1517+ r1 = returnFunc()
1518+ } else {
1519+ r1 = ret.Error(1)
1520+ }
1521+ return r0, r1
15071522 }
15081523
15091524 // MockCatalogue_Records_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Records'
@@ -1523,12 +1538,12 @@ func (_c *MockCatalogue_Records_Call) Run(run func()) *MockCatalogue_Records_Cal
15231538 return _c
15241539 }
15251540
1526-func (_c *MockCatalogue_Records_Call) Return(catalogueRecords []domain.CatalogueRecord) *MockCatalogue_Records_Call {
1527- _c.Call.Return(catalogueRecords)
1541+func (_c *MockCatalogue_Records_Call) Return(catalogueRecords []domain.CatalogueRecord, err error) *MockCatalogue_Records_Call {
1542+ _c.Call.Return(catalogueRecords, err)
15281543 return _c
15291544 }
15301545
1531-func (_c *MockCatalogue_Records_Call) RunAndReturn(run func() []domain.CatalogueRecord) *MockCatalogue_Records_Call {
1546+func (_c *MockCatalogue_Records_Call) RunAndReturn(run func() ([]domain.CatalogueRecord, error)) *MockCatalogue_Records_Call {
15321547 _c.Call.Return(run)
15331548 return _c
15341549 }
M internal/ports/recovery.go +2 -2
@@ -157,8 +157,8 @@ type DegradedRecoveryCoordinator interface {
157157 }
158158
159159 type Catalogue interface {
160- Records() []domain.CatalogueRecord
161- Record(string) (domain.CatalogueRecord, bool)
160+ Records() ([]domain.CatalogueRecord, error)
161+ Record(string) (domain.CatalogueRecord, bool, error)
162162 Create(domain.CatalogueRecord) error
163163 UpdateMetadata(domain.CatalogueMetadataUpdate) error
164164 Replace(string, domain.CatalogueRecord) error
M internal/ports/store.go +5 -0
@@ -21,6 +21,11 @@ type Store interface {
2121 Get(key []byte) ([]byte, bool)
2222 Set(key, val []byte) error
2323 Delete(key []byte) error
24+ // Batch applies all changes atomically to in-memory state: an error leaves
25+ // every prior value unchanged. Implementations repair or discard any torn
26+ // WAL batch during replay so no partial batch becomes visible after restart.
27+ // Batch does not retain StoreChange key or value slices; the caller owns and
28+ // may mutate them after Batch returns.
2429 Batch([]StoreChange) error
2530 // Range iterates key/value pairs; fn returning false stops iteration early.
2631 // Each key and value is a stable copy owned by the caller, which may retain
M internal/ports/wire_test.go +21 -6
@@ -744,12 +744,27 @@ func TestKillGoldenAndRoundTrip(t *testing.T) {
744744 }
745745
746746 func TestSessionInfoRecoveryState(t *testing.T) {
747- payload := MarshalSessions(Sessions{Sessions: []SessionInfo{{Name: "broken", State: SessionDegraded}}})
748- got, err := UnmarshalSessions(payload)
749- require.NoError(t, err)
750- require.Equal(t, SessionDegraded, got.Sessions[0].State)
751- assertAllPrefixesFail(t, payload, UnmarshalSessions)
752- assertTrailingGarbageFails(t, payload, UnmarshalSessions)
747+ tests := []struct {
748+ name string
749+ state SessionState
750+ want []byte
751+ }{
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}},
756+ }
757+ for _, tt := range tests {
758+ t.Run(tt.name, func(t *testing.T) {
759+ payload := MarshalSessions(Sessions{Sessions: []SessionInfo{{State: tt.state}}})
760+ require.Equal(t, tt.want, payload)
761+ got, err := UnmarshalSessions(payload)
762+ require.NoError(t, err)
763+ require.Equal(t, tt.state, got.Sessions[0].State)
764+ assertAllPrefixesFail(t, payload, UnmarshalSessions)
765+ assertTrailingGarbageFails(t, payload, UnmarshalSessions)
766+ })
767+ }
753768 }
754769
755770 func TestSessionsGoldenAndRoundTrip(t *testing.T) {
M internal/usecase/command/command.go +12 -3
@@ -8,9 +8,9 @@ import (
88 // ErrInvalidArguments reports arguments that do not have the exact command format.
99 var ErrInvalidArguments = errors.New("invalid command arguments")
1010
11-// ParsePositiveDecimal accepts exactly one base-10 positive decimal value.
11+// ParsePositiveUint64 accepts exactly one base-10 positive decimal value.
1212 // It deliberately rejects signs, whitespace, zero, and non-canonical forms.
13-func ParsePositiveDecimal(args []string) (int, error) {
13+func ParsePositiveUint64(args []string) (uint64, error) {
1414 if len(args) != 1 || args[0] == "" || (len(args[0]) > 1 && args[0][0] == '0') {
1515 return 0, ErrInvalidArguments
1616 }
@@ -19,13 +19,22 @@ func ParsePositiveDecimal(args []string) (int, error) {
1919 return 0, ErrInvalidArguments
2020 }
2121 }
22- n, err := strconv.Atoi(args[0])
22+ n, err := strconv.ParseUint(args[0], 10, 64)
2323 if err != nil || n < 1 {
2424 return 0, ErrInvalidArguments
2525 }
2626 return n, nil
2727 }
2828
29+// ParsePositiveDecimal parses a positive decimal that must fit the platform int.
30+func ParsePositiveDecimal(args []string) (int, error) {
31+ n, err := ParsePositiveUint64(args)
32+ if err != nil || n > uint64(^uint(0)>>1) {
33+ return 0, ErrInvalidArguments
34+ }
35+ return int(n), nil
36+}
37+
2938 // Context is the set of application actions available to commands.
3039 type Context interface {
3140 CreateTab() error
M internal/usecase/command/command_test.go +11 -0
@@ -5,6 +5,17 @@ import (
55 "testing"
66 )
77
8+func TestParsePositiveUint64(t *testing.T) {
9+ const maxUint64 = "18446744073709551615"
10+ got, err := ParsePositiveUint64([]string{maxUint64})
11+ if err != nil || got != ^uint64(0) {
12+ t.Fatalf("ParsePositiveUint64(max) = %d, %v", got, err)
13+ }
14+ if _, err := ParsePositiveUint64([]string{"18446744073709551616"}); !errors.Is(err, ErrInvalidArguments) {
15+ t.Fatalf("ParsePositiveUint64(overflow) error = %v", err)
16+ }
17+}
18+
819 func TestParsePositiveDecimal(t *testing.T) {
920 for _, tt := range []struct {
1021 name string
M internal/usecase/command/registry.go +6 -3
@@ -1,6 +1,9 @@
11 package command
22
3-import "strings"
3+import (
4+ "path/filepath"
5+ "strings"
6+)
47
58 // Registry returns all palette and control commands in stable display order.
69 func Registry() []Command {
@@ -101,11 +104,11 @@ func sessionRecoveryCommand() Command {
101104 case len(args) == 1 && (args[0] == "retry" || args[0] == "discard"):
102105 action = args[0]
103106 case len(args) == 2 && args[0] == "restore":
104- if _, err := ParsePositiveDecimal(args[1:]); err != nil {
107+ if _, err := ParsePositiveUint64(args[1:]); err != nil {
105108 return ControlResult{}, ErrInvalidArguments
106109 }
107110 action, argument = args[0], args[1]
108- case len(args) == 2 && args[0] == "export" && args[1] != "":
111+ case len(args) == 2 && args[0] == "export" && filepath.IsAbs(args[1]):
109112 action, argument = args[0], args[1]
110113 default:
111114 return ControlResult{}, ErrInvalidArguments
M internal/usecase/command/registry_test.go +22 -6
@@ -117,16 +117,29 @@ func TestSessionRecoveryCommand(t *testing.T) {
117117 }{
118118 {args: []string{"retry"}, wantCall: "session-recovery:retry:"},
119119 {args: []string{"restore", "7"}, wantCall: "session-recovery:restore:7"},
120+ {args: []string{"restore", "18446744073709551615"}, wantCall: "session-recovery:restore:18446744073709551615"},
120121 {args: []string{"export", "/tmp/export"}, wantCall: "session-recovery:export:/tmp/export"},
121122 {args: []string{"discard"}, wantCall: "session-recovery:discard:"},
123+ {args: nil, wantErr: ErrInvalidArguments},
124+ {args: []string{"retry", "extra"}, wantErr: ErrInvalidArguments},
125+ {args: []string{"restore"}, wantErr: ErrInvalidArguments},
122126 {args: []string{"restore", "0"}, wantErr: ErrInvalidArguments},
127+ {args: []string{"restore", "01"}, wantErr: ErrInvalidArguments},
128+ {args: []string{"restore", "18446744073709551616"}, wantErr: ErrInvalidArguments},
129+ {args: []string{"export", "relative/path"}, wantErr: ErrInvalidArguments},
130+ {args: []string{"export"}, wantErr: ErrInvalidArguments},
131+ {args: []string{"discard", "extra"}, wantErr: ErrInvalidArguments},
123132 {args: []string{"unknown"}, wantErr: ErrInvalidArguments},
124133 }
125134 for _, test := range tests {
126- ctx := &controlSpy{}
127- _, err := cmd.Control(ctx, test.args, ControlOptions{})
128- if !errors.Is(err, test.wantErr) || ctx.call != test.wantCall {
129- t.Errorf("args %v: call/error = %q/%v, want %q/%v", test.args, ctx.call, err, test.wantCall, test.wantErr)
135+ ctx := &controlSpy{recoveryOutput: "recovery output"}
136+ got, err := cmd.Control(ctx, test.args, ControlOptions{})
137+ wantOutput := ""
138+ if test.wantCall != "" {
139+ wantOutput = ctx.recoveryOutput
140+ }
141+ if !errors.Is(err, test.wantErr) || ctx.call != test.wantCall || got.Output != wantOutput {
142+ t.Errorf("args %v: call/output/error = %q/%q/%v, want %q/%q/%v", test.args, ctx.call, got.Output, err, test.wantCall, wantOutput, test.wantErr)
130143 }
131144 }
132145 }
@@ -175,7 +188,10 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) {
175188 }
176189 }
177190
178-type controlSpy struct{ call string }
191+type controlSpy struct {
192+ call string
193+ recoveryOutput string
194+}
179195
180196 func (s *controlSpy) record(call string) error { s.call = call; return nil }
181197 func (s *controlSpy) CreateTab() error { return s.record("new-tab") }
@@ -205,7 +221,7 @@ func (s *controlSpy) Toast(level, message string) error {
205221 return s.record("toast:" + level + ":" + message)
206222 }
207223 func (s *controlSpy) SessionRecovery(action, argument string) (string, error) {
208- return "", s.record("session-recovery:" + action + ":" + argument)
224+ return s.recoveryOutput, s.record("session-recovery:" + action + ":" + argument)
209225 }
210226 func (s *controlSpy) ListSessions(json bool) (string, error) {
211227 _ = s.record("list-sessions:" + strconv.FormatBool(json))
M internal/usecase/daemon/catalogue.go +56 -11
@@ -7,30 +7,75 @@ import (
77 // Catalogue helpers keep persistence optional for ephemeral and focused unit
88 // tests while ensuring every durable named-session mutation uses the authority
99 // port when one is configured.
10-func (d *Daemon) catalogueRecord(name string) (domain.CatalogueRecord, bool) {
10+func (d *Daemon) catalogueRecord(name string) (domain.CatalogueRecord, bool, error) {
1111 if d == nil || d.catalogue == nil {
12- return domain.CatalogueRecord{}, false
12+ return domain.CatalogueRecord{}, false, nil
1313 }
1414 return d.catalogue.Record(name)
1515 }
1616
17-func (d *Daemon) createCatalogueRecord(record domain.CatalogueRecord) error {
17+func (d *Daemon) updateCatalogueMetadata(update domain.CatalogueMetadataUpdate) error {
1818 if d == nil || d.catalogue == nil {
1919 return nil
2020 }
21- return d.catalogue.Create(record)
21+ return d.catalogue.UpdateMetadata(update)
2222 }
2323
24-func (d *Daemon) updateCatalogueMetadata(update domain.CatalogueMetadataUpdate) error {
25- if d == nil || d.catalogue == nil {
24+// persistSessionMetadata serializes authority writes without holding daemon or
25+// session state locks during I/O. Only a successfully committed newer revision
26+// subsumes an older caller. Failed revisions retain their rollback until later
27+// failed mutations have unwound, keeping live metadata at the durable boundary.
28+func (d *Daemon) persistSessionMetadata(sess *session, version uint64, update domain.CatalogueMetadataUpdate, rollback func() bool) error {
29+ if sess == nil {
2630 return nil
2731 }
28- return d.catalogue.UpdateMetadata(update)
29-}
32+ sess.metadataPersistMu.Lock()
33+ defer sess.metadataPersistMu.Unlock()
3034
31-func (d *Daemon) deleteCatalogueRecord(name string) error {
32- if d == nil || d.catalogue == nil {
35+ if sess.metadataDurableVersion >= version {
3336 return nil
3437 }
35- return d.catalogue.Delete(name)
38+ if err := d.updateCatalogueMetadata(update); err != nil {
39+ if sess.metadataFailedRollbacks == nil {
40+ sess.metadataFailedRollbacks = make(map[uint64]func() bool)
41+ }
42+ sess.metadataFailedRollbacks[version] = rollback
43+ // A later mutation may have been reserved while authority I/O blocked.
44+ // It must settle before an older failed mutation can be unwound.
45+ sess.mu.Lock()
46+ current := sess.metadataVersion
47+ sess.mu.Unlock()
48+ if current > sess.metadataLiveVersion {
49+ sess.metadataLiveVersion = current
50+ }
51+ sess.reconcileFailedMetadataLocked()
52+ return err
53+ }
54+
55+ sess.metadataDurableVersion = version
56+ for failedVersion := range sess.metadataFailedRollbacks {
57+ if failedVersion <= version {
58+ delete(sess.metadataFailedRollbacks, failedVersion)
59+ }
60+ }
61+ return nil
62+}
63+
64+// reconcileFailedMetadataLocked unwinds only a contiguous failed suffix. An
65+// older failure remains pending while a newer mutation is still writing.
66+// Rollbacks run only after authority I/O has returned and acquire state locks in
67+// the daemon-before-session order.
68+func (s *session) reconcileFailedMetadataLocked() {
69+ for s.metadataLiveVersion > s.metadataDurableVersion {
70+ version := s.metadataLiveVersion
71+ rollback, failed := s.metadataFailedRollbacks[version]
72+ if !failed {
73+ return
74+ }
75+ if rollback != nil && !rollback() {
76+ return
77+ }
78+ delete(s.metadataFailedRollbacks, version)
79+ s.metadataLiveVersion--
80+ }
3681 }
M internal/usecase/daemon/control.go +37 -22
@@ -6,6 +6,8 @@ import (
66 "errors"
77 "fmt"
88 "os"
9+ "path/filepath"
10+ "slices"
911 "sort"
1012 "strconv"
1113 "strings"
@@ -30,14 +32,14 @@ func (d *Daemon) handleCommand(tr ports.Transport, f ports.Frame) {
3032 _ = tr.Send(frameCommandResult(ports.CommandResult{Code: ports.ErrInternal, Text: "malformed command request"}))
3133 return
3234 }
33- _ = tr.Send(frameCommandResult(d.dispatchCommand(request)))
35+ _ = tr.Send(frameCommandResult(d.dispatchCommand(d.serveCtx, request)))
3436 }
3537
3638 func frameCommandResult(result ports.CommandResult) ports.Frame {
3739 return ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(result)}
3840 }
3941
40-func (d *Daemon) dispatchCommand(request ports.CommandRequest) ports.CommandResult {
42+func (d *Daemon) dispatchCommand(ctx context.Context, request ports.CommandRequest) ports.CommandResult {
4143 cmd, ok := command.BySlug(request.Slug)
4244 if !ok {
4345 return commandFailure(ports.ErrUnknownCommand, "unknown command: "+request.Slug)
@@ -46,7 +48,7 @@ func (d *Daemon) dispatchCommand(request ports.CommandRequest) ports.CommandResu
4648 return commandFailure(ports.ErrNotScriptable, request.Slug+" requires an attached client")
4749 }
4850 if cmd.Target == command.TargetNone && !request.Self {
49- return d.runControl(cmd, controlExec{d: d, recoveryName: request.TargetSession}, request)
51+ return d.runControl(cmd, controlExec{ctx: ctx, d: d, recoveryName: request.TargetSession}, request)
5052 }
5153
5254 sess, code, text := d.resolveTargetSession(request)
@@ -60,7 +62,7 @@ func (d *Daemon) dispatchCommand(request ports.CommandRequest) ports.CommandResu
6062 if code != 0 {
6163 return commandFailure(code, text)
6264 }
63- return d.runControl(cmd, controlExec{d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)
65+ return d.runControl(cmd, controlExec{ctx: ctx, d: d, sess: sess, tab: tb, target: daemonActionTarget{session: sess, tab: tb, pane: pane}}, request)
6466 }
6567
6668 func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports.CommandRequest) ports.CommandResult {
@@ -309,14 +311,7 @@ func (d *Daemon) hasDaemonActionPaneTarget(target daemonActionTarget) bool {
309311 }
310312 target.session.mu.Lock()
311313 defer target.session.mu.Unlock()
312- foundTab := false
313- for _, tb := range target.session.tabs {
314- if tb == target.tab {
315- foundTab = true
316- break
317- }
318- }
319- if !foundTab {
314+ if !slices.Contains(target.session.tabs, target.tab) {
320315 return false
321316 }
322317 target.tab.mu.Lock()
@@ -335,6 +330,7 @@ func (a daemonActions) switchRelative(sess *session, delta int) error {
335330 // controlExec implements command.ControlContext against resolved daemon-owned
336331 // targets. It deliberately contains no attached-client state.
337332 type controlExec struct {
333+ ctx context.Context
338334 d *Daemon
339335 sess *session
340336 tab *tab
@@ -465,7 +461,10 @@ func (e controlExec) SessionRecovery(action, argument string) (string, error) {
465461 if e.d == nil || e.d.degradedRecovery == nil || e.recoveryName == "" {
466462 return "", command.ErrInvalidArguments
467463 }
468- ctx := context.Background()
464+ ctx := e.ctx
465+ if ctx == nil {
466+ ctx = e.d.serveCtx
467+ }
469468 switch action {
470469 case "retry":
471470 if err := e.d.degradedRecovery.Retry(ctx, e.recoveryName); err != nil {
@@ -480,7 +479,10 @@ func (e controlExec) SessionRecovery(action, argument string) (string, error) {
480479 if err != nil || generation == 0 {
481480 return "", command.ErrInvalidArguments
482481 }
483- record, ok := e.d.catalogue.Record(e.recoveryName)
482+ record, ok, err := e.d.catalogue.Record(e.recoveryName)
483+ if err != nil {
484+ return "", err
485+ }
484486 if !ok {
485487 return "", errors.New("session recovery record not found")
486488 }
@@ -494,6 +496,9 @@ func (e controlExec) SessionRecovery(action, argument string) (string, error) {
494496 }
495497 return "", errors.New("requested recovery fallback is not available")
496498 case "export":
499+ if !filepath.IsAbs(argument) {
500+ return "", command.ErrInvalidArguments
501+ }
497502 file, err := os.OpenFile(argument, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
498503 if err != nil {
499504 return "", err
@@ -520,20 +525,30 @@ func (e controlExec) restoreExplicitRecovery(ctx context.Context) error {
520525 if e.d.catalogue == nil {
521526 return errors.New("durable session catalogue unavailable")
522527 }
523- record, ok := e.d.catalogue.Record(e.recoveryName)
528+ record, ok, err := e.d.catalogue.Record(e.recoveryName)
529+ if err != nil {
530+ return err
531+ }
524532 if !ok {
525533 return errors.New("session recovery record not found")
526534 }
527535 done := make(chan struct{})
528536 e.d.mu.Lock()
529- if entry, exists := e.d.stopped[record.Name]; exists {
530- entry.record = record
531- entry.state = runtimeRestoring
532- entry.restoreDone = done
533- e.d.stopped[record.Name] = entry
534- }
537+ entry, exists := e.d.stopped[record.Name]
538+ if !exists || entry.incarnation != record.IncarnationID {
539+ e.d.mu.Unlock()
540+ return errors.New("session recovery runtime record not found")
541+ }
542+ if entry.purging || entry.state != runtimeDegraded {
543+ e.d.mu.Unlock()
544+ return errors.New("session recovery is not available in the current state")
545+ }
546+ entry.record = record
547+ entry.state = runtimeRestoring
548+ entry.restoreDone = done
549+ e.d.stopped[record.Name] = entry
535550 e.d.mu.Unlock()
536- err := e.d.restoreRecord(ctx, record)
551+ err = e.d.restoreRecord(ctx, record)
537552 e.d.finishRecordRestore(record, err, done)
538553 return err
539554 }
M internal/usecase/daemon/daemon.go +72 -70
@@ -7,13 +7,13 @@ import (
77 "log/slog"
88 "math"
99 "os"
10+ "reflect"
1011 "sort"
1112 "sync"
1213 "sync/atomic"
1314 "time"
1415
1516 "github.com/bnema/vev/internal/domain"
16- "github.com/bnema/vev/internal/persist"
1717 "github.com/bnema/vev/internal/ports"
1818 "github.com/bnema/vev/internal/usecase/keys"
1919 )
@@ -70,6 +70,8 @@ type Daemon struct {
7070 mu sync.Mutex
7171 sessions map[domain.SessionID]*session
7272 stopped map[string]stoppedSession
73+ // creating reserves names while durable creation I/O runs without mu.
74+ creating map[string]struct{}
7375 nextID uint64
7476 // lastAllocatedCreatedAt is the named-session lifecycle timestamp high-water
7577 // mark. It is guarded by mu and prevents a wall-clock regression from
@@ -117,13 +119,11 @@ type Daemon struct {
117119 shell string
118120 shellArgs []string
119121 shellOverride bool
120- persist *persist.Persister
121122 persistEnabled bool
122123 catalogue ports.Catalogue
123124 catalogueRecords []domain.CatalogueRecord
124125 catalogueRecordsProvided bool
125- // snapshotRepository is the sole checkpoint contract. legacySnapshots is
126- // read-only migration input and is never used for new writes.
126+ // snapshotRepository is the sole checkpoint contract.
127127 snapshotRepository ports.SnapshotRepository
128128 checkpointRecovery ports.CheckpointCoordinator
129129 lifecycleRecovery ports.SessionLifecycleCoordinator
@@ -136,10 +136,12 @@ type Daemon struct {
136136 // records, so it is a durable writer and is guarded by snapshotWorkerMu with
137137 // the other two.
138138 restoreWorkerDone chan struct{}
139- legacySnapshots ports.LegacySnapshotSource
140139 snapsEnabled bool
141140 noticeStore ports.NoticeStore
142141 snapshotJobs chan *snapshotCapture
142+ // snapshotAdmitted contains every capture accepted by either worker queue,
143+ // including captures buffered in snapshotJobs. Guarded by snapshotWorkerMu.
144+ snapshotAdmitted map[*snapshotCapture]struct{}
143145 // snapshotWake wakes the repository scheduler when a session becomes dirty
144146 // or an attempt completes. It is never closed and producers only send
145147 // non-blockingly.
@@ -263,16 +265,6 @@ func WithShell(cmd string, args []string) Option {
263265 }
264266 }
265267
266-// WithStore enables persisted named session metadata. A nil store keeps the
267-// daemon in no-op persistence mode.
268-func WithStore(store ports.Store) Option {
269- return func(d *Daemon) {
270- d.persist = persist.New(store)
271- d.catalogue = d.persist
272- d.persistEnabled = store != nil
273- }
274-}
275-
276268 // WithCatalogue installs the singular catalogue and the strictly opened
277269 // records that define the daemon's expected-session registry at startup.
278270 func WithCatalogue(catalogue ports.Catalogue, records []domain.CatalogueRecord) Option {
@@ -303,26 +295,30 @@ func WithRecoveryCoordinator(coordinator ports.SessionLifecycleCoordinator) Opti
303295 }
304296 }
305297
306-// WithSnapshotRepository enables content-addressed incremental snapshots. The
307-// legacy source is read-only migration input and is never used for new writes.
308-func WithSnapshotRepository(repository any, legacy ports.LegacySnapshotSource) Option {
298+// WithSnapshotRepository enables content-addressed incremental snapshots.
299+func WithSnapshotRepository(repository ports.SnapshotRepository) Option {
309300 return func(d *Daemon) {
310- if repository == nil {
301+ if isNilSnapshotRepository(repository) {
311302 return
312303 }
313- typed, ok := repository.(ports.SnapshotRepository)
314- if !ok {
315- // Pre-incarnation test repositories are intentionally not admitted to
316- // normal runtime paths. They remain accepted by this option only so
317- // package-level legacy tests can exercise their isolated helpers.
318- return
319- }
320- d.snapshotRepository = typed
321- d.legacySnapshots = legacy
304+ d.snapshotRepository = repository
322305 d.snapsEnabled = true
323306 }
324307 }
325308
309+func isNilSnapshotRepository(repository ports.SnapshotRepository) bool {
310+ if repository == nil {
311+ return true
312+ }
313+ value := reflect.ValueOf(repository)
314+ switch value.Kind() {
315+ case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
316+ return value.IsNil()
317+ default:
318+ return false
319+ }
320+}
321+
326322 // WithNoticeStore enables persisting undeliverable notices across daemon
327323 // restarts. A nil store keeps the daemon in no-op notice-persistence mode.
328324 func WithNoticeStore(store ports.NoticeStore) Option {
@@ -420,6 +416,9 @@ func (d *Daemon) allocateLifecycleCreatedAtLocked() (int64, error) {
420416 }
421417
422418 func (d *Daemon) nowUnixNano() int64 {
419+ if d == nil || d.clock == nil {
420+ return time.Now().UnixNano()
421+ }
423422 return d.clock.Now().UnixNano()
424423 }
425424
@@ -434,23 +433,24 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
434433 shell = "/bin/sh"
435434 }
436435 d := &Daemon{
437- sessions: make(map[domain.SessionID]*session),
438- stopped: make(map[string]stoppedSession),
439- parked: make(map[uint64]*parkedAttachment),
440- ptys: ptys,
441- clock: clock,
442- log: log,
443- baseEnv: os.Environ(),
444- shell: shell,
445- persist: persist.New(nil),
446- dirOrHome: dirOrHome,
447- done: make(chan struct{}),
448- restoreDone: make(chan struct{}),
449- animWake: make(chan struct{}, 1),
450- snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
451- snapshotWake: make(chan struct{}, 1),
452- notices: newNoticeCenter(),
453- resumeParkGrace: defaultResumeParkGrace,
436+ sessions: make(map[domain.SessionID]*session),
437+ stopped: make(map[string]stoppedSession),
438+ creating: make(map[string]struct{}),
439+ parked: make(map[uint64]*parkedAttachment),
440+ ptys: ptys,
441+ clock: clock,
442+ log: log,
443+ baseEnv: os.Environ(),
444+ shell: shell,
445+ dirOrHome: dirOrHome,
446+ done: make(chan struct{}),
447+ restoreDone: make(chan struct{}),
448+ animWake: make(chan struct{}, 1),
449+ snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
450+ snapshotAdmitted: make(map[*snapshotCapture]struct{}),
451+ snapshotWake: make(chan struct{}, 1),
452+ notices: newNoticeCenter(),
453+ resumeParkGrace: defaultResumeParkGrace,
454454 barScripts: &barScriptState{
455455 cfg: barConfigFromDomain(domain.Defaults().Bar),
456456 outputs: make(map[domain.SessionID]barScriptOutputs),
@@ -470,9 +470,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
470470 for _, o := range opts {
471471 o(d)
472472 }
473- if d.persist == nil {
474- d.persist = persist.New(nil)
475- }
476473 if d.dirOrHome == nil {
477474 d.dirOrHome = dirOrHome
478475 }
@@ -488,20 +485,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
488485 d.restoreProcessAllowlist.Store(&allow)
489486 }
490487 records := d.catalogueRecords
491- // Only attempt the load when persistence is actually configured
492- // (d.persistEnabled tracks the same store-is-non-nil condition
493- // persist.Persister uses to distinguish "no store" from "load failed").
494- // Without this gate, the default no-persistence Daemon (persist.New(nil))
495- // would log a false-alarm "loading persisted sessions failed" warning on
496- // every construction: nothing failed, persistence simply isn't enabled.
497- if !d.catalogueRecordsProvided && d.persistEnabled {
498- var err error
499- records, err = d.persist.LoadAll()
500- if err != nil {
501- d.log.Warn("loading persisted sessions failed", "err", err)
502- return d
503- }
504- }
505488 var maxSeq uint64
506489 var maxCreatedAt int64
507490 hasCreatedAt := false
@@ -628,8 +611,6 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
628611 var closeErr error
629612 if d.catalogue != nil {
630613 closeErr = d.catalogue.Close()
631- } else {
632- closeErr = d.persist.Close()
633614 }
634615 if closeErr != nil {
635616 d.log.Warn("closing session persister failed", "err", closeErr)
@@ -662,11 +643,23 @@ func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapsho
662643 for _, s := range snapshot {
663644 // Cancellation and PTY closure must not wait behind a teardown owner that
664645 // is blocked in snapshot publication or purge work.
646+ s.mu.Lock()
647+ name := s.name
648+ ephemeral := s.ephemeral
649+ s.mu.Unlock()
665650 s.stopInMemoryLifecycle()
666651 if err := d.killSessionWithSnapshotDeadline(s, reason, false, deadline); err != nil {
667652 checkpointIncomplete = true
668653 d.log.Error("closing session with unpersisted terminal state", "err", err)
669654 }
655+ if !ephemeral && deadline != nil {
656+ select {
657+ case <-deadline.Done():
658+ checkpointIncomplete = true
659+ d.persistShutdownSnapshotFailure(name, context.DeadlineExceeded)
660+ default:
661+ }
662+ }
670663 }
671664 return checkpointIncomplete
672665 }
@@ -738,6 +731,12 @@ func snapshotStopContext(deadline *snapshotShutdownDeadline) (context.Context, c
738731 if deadline == nil {
739732 return ctx, cancel
740733 }
734+ select {
735+ case <-deadline.Done():
736+ cancel()
737+ return ctx, cancel
738+ default:
739+ }
741740 stop := make(chan struct{})
742741 go func() {
743742 select {
@@ -817,13 +816,16 @@ func (d *Daemon) handleList(tr ports.Transport) {
817816 }
818817 state := ports.SessionStopped
819818 if stopped.purging {
819+ // Purge is the dominant externally visible state: restoration must
820+ // never make a deletion-reserved record appear attachable.
820821 state = ports.SessionDegraded
821- }
822- switch stopped.state {
823- case runtimeRestoring:
824- state = ports.SessionRestoring
825- case runtimeDegraded, runtimeDeleting:
826- state = ports.SessionDegraded
822+ } else {
823+ switch stopped.state {
824+ case runtimeRestoring:
825+ state = ports.SessionRestoring
826+ case runtimeDegraded, runtimeDeleting:
827+ state = ports.SessionDegraded
828+ }
827829 }
828830 infos = append(infos, ports.SessionInfo{Name: name, State: state})
829831 }
A internal/usecase/daemon/durable_authority_test.go +49 -0
@@ -0,0 +1,49 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/persist"
9+ "github.com/bnema/vev/internal/ports"
10+ recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
11+)
12+
13+type testLifecycleRepository struct{ ports.SnapshotRepository }
14+
15+func (testLifecycleRepository) WriteDeletionTombstone(context.Context, domain.DeletionTombstone) error {
16+ return nil
17+}
18+func (testLifecycleRepository) QuarantineDeletionSources(context.Context, domain.DeletionTombstone, bool) error {
19+ return nil
20+}
21+func (testLifecycleRepository) DeleteDeletionTombstone(context.Context, domain.IncarnationID) error {
22+ return nil
23+}
24+
25+// WithStore is package-test composition glue. Production injects the same
26+// recovery coordinator explicitly from internal/app.
27+func WithStore(store ports.Store) Option {
28+ return func(d *Daemon) {
29+ if store == nil {
30+ return
31+ }
32+ catalogue := persist.New(store)
33+ records, err := catalogue.Records()
34+ if err != nil {
35+ return
36+ }
37+ d.catalogue = catalogue
38+ d.persistEnabled = true
39+ d.catalogueRecords = records
40+ d.catalogueRecordsProvided = true
41+ repository := d.snapshotRepository
42+ if repository == nil {
43+ repository = testLifecycleRepository{}
44+ }
45+ authority := recoveryusecase.NewCoordinator(catalogue, repository, nil, rand.Reader)
46+ d.lifecycleRecovery = authority
47+ d.checkpointRecovery = authority
48+ }
49+}
M internal/usecase/daemon/durable_recovery_test.go +126 -24
@@ -16,6 +16,8 @@ import (
1616
1717 "github.com/bnema/vev/internal/domain"
1818 "github.com/bnema/vev/internal/ports"
19+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
20+ "github.com/bnema/vev/internal/usecase/command"
1921 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
2022 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2123 )
@@ -35,21 +37,28 @@ func newDurableRecoveryCatalogue(records []domain.CatalogueRecord) *durableRecov
3537 return catalogue
3638 }
3739
38-func (c *durableRecoveryCatalogue) Records() []domain.CatalogueRecord {
40+func (c *durableRecoveryCatalogue) Records() ([]domain.CatalogueRecord, error) {
3941 c.mu.Lock()
4042 defer c.mu.Unlock()
4143 records := make([]domain.CatalogueRecord, 0, len(c.records))
4244 for _, record := range c.records {
4345 records = append(records, record)
4446 }
45- return records
47+ return records, nil
4648 }
4749
48-func (c *durableRecoveryCatalogue) Record(name string) (domain.CatalogueRecord, bool) {
50+func (c *durableRecoveryCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
4951 c.mu.Lock()
5052 defer c.mu.Unlock()
5153 record, ok := c.records[name]
52- return record, ok
54+ return record, ok, nil
55+}
56+
57+func mustDurableRecords(t *testing.T, c *durableRecoveryCatalogue) []domain.CatalogueRecord {
58+ t.Helper()
59+ records, err := c.Records()
60+ require.NoError(t, err)
61+ return records
5362 }
5463
5564 func (c *durableRecoveryCatalogue) Replace(name string, record domain.CatalogueRecord) error {
@@ -111,6 +120,9 @@ type durableRecoveryRepository struct {
111120 func (r *durableRecoveryRepository) LoadCheckpoint(_ context.Context, id domain.IncarnationID, name string, ref ports.CheckpointRef) (ports.SnapshotGeneration, error) {
112121 r.mu.Lock()
113122 defer r.mu.Unlock()
123+ if r.loads == nil {
124+ r.loads = make(map[string]int)
125+ }
114126 r.loads[name]++
115127 if err := r.errors[name]; err != nil {
116128 return ports.SnapshotGeneration{}, err
@@ -129,6 +141,9 @@ func (r *durableRecoveryRepository) RepairHEAD(_ context.Context, id domain.Inca
129141 if r.repairErr != nil {
130142 return r.repairErr
131143 }
144+ if r.repairs == nil {
145+ r.repairs = make(map[string]domain.CheckpointRef)
146+ }
132147 r.repairs[id.String()] = ref
133148 return nil
134149 }
@@ -169,7 +184,7 @@ func newDurableRecoveryDaemon(t testing.TB, records []domain.CatalogueRecord, re
169184 t.Helper()
170185 catalogue := newDurableRecoveryCatalogue(records)
171186 coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil, nil)
172- d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(catalogue, records), WithSnapshotRepository(repository, nil), WithCheckpointCoordinator(coordinator))
187+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(catalogue, records), WithSnapshotRepository(repository), WithCheckpointCoordinator(coordinator))
173188 d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
174189 t.Cleanup(d.serveCancel)
175190 return d, catalogue
@@ -188,7 +203,7 @@ func TestFinalCheckpointFailurePreservesCatalogue(t *testing.T) {
188203 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{prior})
189204 repository := &durableRecoveryRepository{}
190205 coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil, nil)
191- d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(catalogue, []domain.CatalogueRecord{prior}), WithSnapshotRepository(repository, nil), WithCheckpointCoordinator(coordinator))
206+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(catalogue, []domain.CatalogueRecord{prior}), WithSnapshotRepository(repository), WithCheckpointCoordinator(coordinator))
192207 startSnapshotEncodeWorker(t, d)
193208
194209 sess := newSnapshotTestSession(t, prior.Name, false, prior.Cwd)
@@ -204,7 +219,7 @@ func TestFinalCheckpointFailurePreservesCatalogue(t *testing.T) {
204219
205220 require.True(t, d.scheduleFinalSnapshot(sess))
206221 awaitSnapshotIdle(t, sess)
207- got, ok := catalogue.Record(prior.Name)
222+ got, ok, _ := catalogue.Record(prior.Name)
208223 require.True(t, ok)
209224 require.Equal(t, prior, got, "a timed-out final checkpoint must not change authoritative bytes")
210225 require.Equal(t, priorRef, *sess.snapshotPublishedCheckpoint)
@@ -214,7 +229,7 @@ func TestCatalogueRestoreIndependent(t *testing.T) {
214229 emptyDaemon, emptyCatalogue := newDurableRecoveryDaemon(t, nil, &durableRecoveryRepository{})
215230 emptyDone := make(chan struct{})
216231 go func() {
217- emptyDaemon.restoreCatalogue(context.Background(), emptyCatalogue.Records())
232+ emptyDaemon.restoreCatalogue(context.Background(), mustDurableRecords(t, emptyCatalogue))
218233 close(emptyDone)
219234 }()
220235 select {
@@ -233,7 +248,7 @@ func TestCatalogueRestoreIndependent(t *testing.T) {
233248 }
234249 d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{healthy, broken}, repository)
235250
236- d.restoreCatalogue(context.Background(), catalogue.Records())
251+ d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
237252
238253 require.Equal(t, runtimeHealthy, d.stopped[healthy.Name].state)
239254 require.Equal(t, runtimeDegraded, d.stopped[broken.Name].state)
@@ -244,7 +259,7 @@ func TestCatalogueRestoreIndependent(t *testing.T) {
244259 t.Fatalf("restore completion for %q was not closed", name)
245260 }
246261 }
247- degraded, ok := catalogue.Record(broken.Name)
262+ degraded, ok, _ := catalogue.Record(broken.Name)
248263 require.True(t, ok)
249264 require.Equal(t, domain.RecoveryDegraded, degraded.RecoveryState)
250265 require.Equal(t, 1, repository.loads[healthy.Name])
@@ -265,8 +280,8 @@ func TestCatalogueRestoreIndependent(t *testing.T) {
265280 repairs: make(map[string]domain.CheckpointRef),
266281 }
267282 fallbackDaemon, fallbackCatalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{fallback}, fallbackRepository)
268- fallbackDaemon.restoreCatalogue(context.Background(), fallbackCatalogue.Records())
269- promoted, ok := fallbackCatalogue.Record(fallback.Name)
283+ fallbackDaemon.restoreCatalogue(context.Background(), mustDurableRecords(t, fallbackCatalogue))
284+ promoted, ok, _ := fallbackCatalogue.Record(fallback.Name)
270285 require.True(t, ok)
271286 require.Equal(t, fallbackRef, *promoted.Committed)
272287 require.Equal(t, runtimeHealthy, fallbackDaemon.stopped[fallback.Name].state)
@@ -291,9 +306,9 @@ func TestCatalogueRestoreKeepsCommittedFallbackHealthyWhenHEADRepairFails(t *tes
291306 }
292307
293308 d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)
294- d.restoreCatalogue(context.Background(), catalogue.Records())
309+ d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
295310
296- committed, ok := catalogue.Record(record.Name)
311+ committed, ok, _ := catalogue.Record(record.Name)
297312 require.True(t, ok)
298313 require.Equal(t, domain.RecoveryHealthy, committed.RecoveryState)
299314 require.Equal(t, fallbackRef, *committed.Committed)
@@ -302,7 +317,7 @@ func TestCatalogueRestoreKeepsCommittedFallbackHealthyWhenHEADRepairFails(t *tes
302317
303318 repository.repairErr = nil
304319 next, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{committed}, repository)
305- next.restoreCatalogue(context.Background(), catalogue.Records())
320+ next.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
306321 require.Equal(t, runtimeHealthy, next.stopped[record.Name].state)
307322 require.Equal(t, 2, repository.repairCalls, "the next startup retries the pending HEAD repair")
308323 require.Equal(t, fallbackRef, repository.repairs[record.IncarnationID.String()])
@@ -318,7 +333,7 @@ func TestCatalogueRestoreAggregateOver4096(t *testing.T) {
318333 }
319334 d, catalogue := newDurableRecoveryDaemon(t, records, repository)
320335
321- d.restoreCatalogue(context.Background(), catalogue.Records())
336+ d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
322337
323338 require.Len(t, d.stopped, count)
324339 for _, record := range records {
@@ -334,7 +349,7 @@ func TestCatalogueRestoreSingleSessionOver4096(t *testing.T) {
334349
335350 // The repository may contain any number of stale entries; restoration has no
336351 // discovery API and therefore performs exactly one direct catalogue lookup.
337- d.restoreCatalogue(context.Background(), catalogue.Records())
352+ d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
338353
339354 require.Equal(t, runtimeHealthy, d.stopped[record.Name].state)
340355 require.Equal(t, 1, repository.loads[record.Name])
@@ -347,10 +362,10 @@ func TestCatalogueRestoreIncarnationMismatch(t *testing.T) {
347362 repository := &durableRecoveryRepository{generations: map[string]ports.SnapshotGeneration{record.Name: generation}, errors: make(map[string]error), loads: make(map[string]int), repairs: make(map[string]domain.CheckpointRef)}
348363 d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)
349364
350- d.restoreCatalogue(context.Background(), catalogue.Records())
365+ d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
351366
352367 require.Equal(t, runtimeDegraded, d.stopped[record.Name].state)
353- got, _ := catalogue.Record(record.Name)
368+ got, _, _ := catalogue.Record(record.Name)
354369 require.Equal(t, domain.RecoveryDegraded, got.RecoveryState)
355370 }
356371
@@ -391,6 +406,59 @@ func (s explicitRecoveryStub) Discard(context.Context, string, string) (domain.C
391406 return s.record, nil
392407 }
393408
409+type contextRecoveryStub struct {
410+ explicitRecoveryStub
411+ wantKey any
412+ want any
413+ seen bool
414+ err error
415+}
416+
417+func (s *contextRecoveryStub) Retry(ctx context.Context, _ string) error {
418+ s.seen = ctx.Value(s.wantKey) == s.want
419+ return s.err
420+}
421+
422+func TestSessionRecoveryUsesIncomingCommandContext(t *testing.T) {
423+ type contextKey struct{}
424+ key := contextKey{}
425+ ctx := context.WithValue(t.Context(), key, "request")
426+ wantErr := errors.New("stop after context capture")
427+ stub := &contextRecoveryStub{wantKey: key, want: "request", err: wantErr}
428+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
429+ d.degradedRecovery = stub
430+
431+ _, err := (controlExec{ctx: ctx, d: d, recoveryName: "work"}).SessionRecovery("retry", "")
432+ require.ErrorIs(t, err, wantErr)
433+ require.True(t, stub.seen)
434+}
435+
436+func TestExplicitRecoveryRejectsConcurrentRestore(t *testing.T) {
437+ record := durableRecoveryRecord(0)
438+ record.RecoveryState = domain.RecoveryDegraded
439+ record.DegradedReason = "checkpoint validation failed"
440+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
441+ d.mu.Lock()
442+ entry := d.stopped[record.Name]
443+ entry.state = runtimeRestoring
444+ originalDone := entry.restoreDone
445+ d.stopped[record.Name] = entry
446+ d.mu.Unlock()
447+
448+ err := (controlExec{d: d, recoveryName: record.Name}).restoreExplicitRecovery(t.Context())
449+ require.ErrorContains(t, err, "current state")
450+ d.mu.Lock()
451+ require.Equal(t, originalDone, d.stopped[record.Name].restoreDone)
452+ d.mu.Unlock()
453+}
454+
455+func TestSessionRecoveryExportRequiresAbsolutePath(t *testing.T) {
456+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
457+ d.degradedRecovery = explicitRecoveryStub{}
458+ _, err := (controlExec{ctx: t.Context(), d: d, recoveryName: "work"}).SessionRecovery("export", "relative.snapshot")
459+ require.ErrorIs(t, err, command.ErrInvalidArguments)
460+}
461+
394462 func TestSessionRecoveryCommand(t *testing.T) {
395463 degraded := durableRecoveryRecord(2)
396464 degraded.RecoveryState = domain.RecoveryDegraded
@@ -428,6 +496,20 @@ func TestListShowsDegraded(t *testing.T) {
428496 }, listed.Sessions)
429497 }
430498
499+func TestListPurgingDominatesRestoringState(t *testing.T) {
500+ record := durableRecoveryRecord(0)
501+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
502+ d.mu.Lock()
503+ entry := d.stopped[record.Name]
504+ entry.purging = true
505+ entry.state = runtimeRestoring
506+ d.stopped[record.Name] = entry
507+ d.mu.Unlock()
508+
509+ listed := listSessions(t, d)
510+ require.Equal(t, []ports.SessionInfo{{Name: record.Name, State: ports.SessionDegraded}}, listed.Sessions)
511+}
512+
431513 func TestAttachWaitsForRestore(t *testing.T) {
432514 record := durableRecoveryRecord(0)
433515 factory := &recoveryCountingPTYFactory{}
@@ -449,7 +531,9 @@ func TestAttachWaitsForRestore(t *testing.T) {
449531 require.Zero(t, factory.calls.Load())
450532
451533 d.setStoppedRecovery(record, runtimeDegraded)
452- closeRuntimeRestoreDone(d.recordRestoreDone(record.Name))
534+ d.mu.Lock()
535+ closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone)
536+ d.mu.Unlock()
453537 var protocolError *protoErr
454538 require.ErrorAs(t, <-result, &protocolError)
455539 require.Equal(t, ports.ErrSessionDegraded, protocolError.code)
@@ -463,7 +547,7 @@ func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) {
463547 restoreCtx, cancelRestore := context.WithCancel(context.Background())
464548 restored := make(chan struct{})
465549 go func() {
466- d.restoreCatalogue(restoreCtx, catalogue.Records())
550+ d.restoreCatalogue(restoreCtx, mustDurableRecords(t, catalogue))
467551 close(restored)
468552 }()
469553 <-repository.started
@@ -488,14 +572,16 @@ func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) {
488572 default:
489573 t.Fatal("restore completion was not signaled")
490574 }
491- committed, _ := catalogue.Record(record.Name)
575+ committed, _, _ := catalogue.Record(record.Name)
492576 require.Equal(t, entry.record, committed)
493577 }
494578
495579 func TestWaitForTargetRestoreRejectsClosedRestoringChannel(t *testing.T) {
496580 record := durableRecoveryRecord(0)
497581 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
498- closeRuntimeRestoreDone(d.stopped[record.Name].restoreDone)
582+ d.mu.Lock()
583+ closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone)
584+ d.mu.Unlock()
499585
500586 result := make(chan error, 1)
501587 go func() { result <- d.waitForTargetRestore(record.Name) }()
@@ -552,6 +638,22 @@ func TestAttachRejectsDegraded(t *testing.T) {
552638 require.Zero(t, factory.calls.Load())
553639 }
554640
641+func TestRestoreUnavailableReleasesPerRecordWaiters(t *testing.T) {
642+ record := durableRecoveryRecord(0)
643+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, nil)
644+ d.closeRestoreDone()
645+
646+ d.mu.Lock()
647+ entry := d.stopped[record.Name]
648+ d.mu.Unlock()
649+ require.Equal(t, runtimeDegraded, entry.state)
650+ select {
651+ case <-entry.restoreDone:
652+ default:
653+ t.Fatal("per-record restore waiter remained blocked when repository was unavailable")
654+ }
655+}
656+
555657 func TestCatalogueRestoreDegradedVisible(t *testing.T) {
556658 record := durableRecoveryRecord(0)
557659 record.RecoveryState = domain.RecoveryDegraded
@@ -559,7 +661,7 @@ func TestCatalogueRestoreDegradedVisible(t *testing.T) {
559661 repository := &durableRecoveryRepository{generations: make(map[string]ports.SnapshotGeneration), errors: make(map[string]error), loads: make(map[string]int), repairs: make(map[string]domain.CheckpointRef)}
560662 d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)
561663
562- d.restoreCatalogue(context.Background(), catalogue.Records())
664+ d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
563665
564666 require.Equal(t, runtimeDegraded, d.stopped[record.Name].state)
565667 require.Zero(t, repository.loads[record.Name])
D internal/usecase/daemon/legacy_snapshot_test.go +0 -8
@@ -1,8 +0,0 @@
1-package daemon
2-
3-import "context"
4-
5-// importLegacySnapshots is retained only for tests covering the retired
6-// pre-migration bridge. Normal runtime legacy decoding lives exclusively in
7-// the snapshot migration adapter.
8-func (d *Daemon) importLegacySnapshots(context.Context) {}
M internal/usecase/daemon/maintenance.go +33 -8
@@ -18,6 +18,9 @@ type maintenanceReconciler interface {
1818 Step(context.Context, ports.ReconcileCursor) (ports.ReconcileCursor, []ports.ReconcileDecision, error)
1919 }
2020
21+// maintenanceDependencies is exclusively owned by runDurableMaintenance's
22+// single goroutine after startup. Shutdown joins that goroutine before these
23+// cursor and repair sets can be observed or reused.
2124 type maintenanceDependencies struct {
2225 catalogue ports.Catalogue
2326 repository ports.SnapshotRepository
@@ -88,7 +91,13 @@ func (d *Daemon) runDurableMaintenance(ctx context.Context) {
8891 case <-d.restoreDone:
8992 }
9093 for {
91- d.runDurableMaintenanceTick(ctx)
94+ incomplete := d.runDurableMaintenanceTick(ctx)
95+ if ctx.Err() != nil {
96+ return
97+ }
98+ if incomplete {
99+ continue
100+ }
92101 timer := d.clock.NewTimer(maintenanceInterval)
93102 select {
94103 case <-ctx.Done():
@@ -99,10 +108,12 @@ func (d *Daemon) runDurableMaintenance(ctx context.Context) {
99108 }
100109 }
101110
102-func (d *Daemon) runDurableMaintenanceTick(ctx context.Context) {
111+// runDurableMaintenanceTick reports whether bounded work remains and should be
112+// continued immediately rather than delayed until the periodic interval.
113+func (d *Daemon) runDurableMaintenanceTick(ctx context.Context) bool {
103114 deps := &d.maintenance
104115 if deps.catalogue == nil || deps.repository == nil || deps.reconciler == nil {
105- return
116+ return false
106117 }
107118
108119 // Reconcile before collection. Until a complete top-level scan reaches EOF,
@@ -111,11 +122,15 @@ func (d *Daemon) runDurableMaintenanceTick(ctx context.Context) {
111122 next, decisions, err := deps.reconciler.Step(ctx, deps.cursor)
112123 if err != nil {
113124 d.log.Warn("durable_reconciliation_tick", "action", "scan", "cursor", deps.cursor.DirectoryCookie, "done", false, "err", err)
114- return
125+ return false
115126 }
116127 deps.cursor = next
117128 for _, decision := range decisions {
118- record, ok := deps.catalogue.Record(decision.Name)
129+ record, ok, err := deps.catalogue.Record(decision.Name)
130+ if err != nil {
131+ d.log.Warn("durable_reconciliation_tick", "action", "catalogue-read", "done", false, "err", err)
132+ return false
133+ }
119134 if ok && !decision.RetentionResolved {
120135 deps.repairUncertain[record.IncarnationID] = struct{}{}
121136 }
@@ -136,18 +151,27 @@ func (d *Daemon) runDurableMaintenanceTick(ctx context.Context) {
136151 }
137152 d.log.Info("durable_reconciliation_tick", "action", "scan", "cursor", next.DirectoryCookie, "done", next.DirectoryCookie == 0, "err", nil)
138153 if next.DirectoryCookie != 0 {
139- return
154+ return true
140155 }
141156
142- for _, record := range deps.catalogue.Records() {
157+ records, err := deps.catalogue.Records()
158+ if err != nil {
159+ d.log.Warn("durable_maintenance_tick", "action", "catalogue-read", "done", false, "err", err)
160+ return false
161+ }
162+ incomplete := false
163+ for _, record := range records {
143164 if err := ctx.Err(); err != nil {
144- return
165+ return false
145166 }
146167 _, unresolved := deps.unresolved[record.IncarnationID]
147168 _, uncertainRepair := deps.repairUncertain[record.IncarnationID]
148169 restoring := d.incarnationRestoring(record.IncarnationID)
149170 plan := retentionPlan(record, unresolved || uncertainRepair, restoring)
150171 done, err := deps.repository.MaintainSession(ctx, plan, ports.MaintenanceBudget{Entries: maintenanceEntries, Bytes: maintenanceBytes})
172+ if err == nil && !done {
173+ incomplete = true
174+ }
151175 d.log.Info("durable_maintenance_session",
152176 "incarnation", record.IncarnationID.String(),
153177 "action", "retention",
@@ -159,6 +183,7 @@ func (d *Daemon) runDurableMaintenanceTick(ctx context.Context) {
159183 )
160184 }
161185 clear(deps.repairUncertain)
186+ return incomplete
162187 }
163188
164189 func (d *Daemon) incarnationRestoring(id domain.IncarnationID) bool {
M internal/usecase/daemon/maintenance_test.go +4 -4
@@ -28,7 +28,7 @@ func (r *cursorMaintenanceReconciler) Step(_ context.Context, cursor ports.Recon
2828 func TestProductionMaintenanceUsesCatalogueAndResumesCursor(t *testing.T) {
2929 record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh}
3030 catalogue := portsmocks.NewMockCatalogue(t)
31- catalogue.EXPECT().Records().Return([]domain.CatalogueRecord{record}).Once()
31+ catalogue.EXPECT().Records().Return([]domain.CatalogueRecord{record}, nil).Once()
3232 repository := portsmocks.NewMockSnapshotRepository(t)
3333 repository.EXPECT().MaintainSession(mock.Anything, mock.MatchedBy(func(plan ports.RetentionPlan) bool {
3434 return plan.IncarnationID == record.IncarnationID && !plan.PinAll && len(plan.Keep) == 0
@@ -37,9 +37,9 @@ func TestProductionMaintenanceUsesCatalogueAndResumesCursor(t *testing.T) {
3737 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
3838 WithDurableMaintenance(catalogue, repository, reconciler, nil)(d)
3939
40- d.runDurableMaintenanceTick(context.Background())
41- d.runDurableMaintenanceTick(context.Background())
42- d.runDurableMaintenanceTick(context.Background())
40+ require.True(t, d.runDurableMaintenanceTick(context.Background()), "partial reconciliation must continue immediately")
41+ require.False(t, d.runDurableMaintenanceTick(context.Background()))
42+ require.True(t, d.runDurableMaintenanceTick(context.Background()), "the next partial pass must also continue immediately")
4343 if len(reconciler.seen) != 3 || reconciler.seen[0].DirectoryCookie != 0 || reconciler.seen[1].DirectoryCookie != 7 || reconciler.seen[2].DirectoryCookie != 0 {
4444 t.Fatalf("reconciliation cursors = %v, want [0 7 0]", reconciler.seen)
4545 }
A internal/usecase/daemon/metadata_order_test.go +285 -0
@@ -0,0 +1,285 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+type blockingMetadataCatalogue struct {
16+ ports.Catalogue
17+
18+ mu sync.Mutex
19+ calls int
20+ firstStarted chan struct{}
21+ releaseFirst chan struct{}
22+ firstErr error
23+ secondErr error
24+}
25+
26+func (c *blockingMetadataCatalogue) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
27+ c.mu.Lock()
28+ c.calls++
29+ call := c.calls
30+ c.mu.Unlock()
31+ if call == 1 {
32+ close(c.firstStarted)
33+ <-c.releaseFirst
34+ if c.firstErr != nil {
35+ return c.firstErr
36+ }
37+ }
38+ if call == 2 && c.secondErr != nil {
39+ return c.secondErr
40+ }
41+ return c.Catalogue.UpdateMetadata(update)
42+}
43+
44+func newBlockingCatalogue(sess *session, firstErr error) (*durableRecoveryCatalogue, *blockingMetadataCatalogue) {
45+ record := sess.persistRecordLocked(sess.createdAt)
46+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
47+ return catalogue, &blockingMetadataCatalogue{
48+ Catalogue: catalogue,
49+ firstStarted: make(chan struct{}),
50+ releaseFirst: make(chan struct{}),
51+ firstErr: firstErr,
52+ }
53+}
54+
55+func TestMetadataWritesPreserveLaterMutationWhenFirstWriteIsBlocked(t *testing.T) {
56+ firstFailure := errors.New("first metadata write failed")
57+ for _, tt := range []struct {
58+ name string
59+ firstErr error
60+ }{
61+ {name: "delayed success"},
62+ {name: "delayed failure does not roll back newer state", firstErr: firstFailure},
63+ } {
64+ t.Run(tt.name, func(t *testing.T) {
65+ sess := newSnapshotTestSession(t, "work", false, "/work")
66+ catalogue, blocking := newBlockingCatalogue(sess, tt.firstErr)
67+ d := newTestDaemon(t, nil, stubClock{})
68+ d.catalogue = blocking
69+ d.sessions[sess.id] = sess
70+
71+ firstDone := make(chan error, 1)
72+ go func() { firstDone <- d.renameTab(sess, sess.tabs[0], "first") }()
73+ select {
74+ case <-blocking.firstStarted:
75+ case <-time.After(testWaitTimeout):
76+ t.Fatal("first metadata write did not block")
77+ }
78+
79+ secondDone := make(chan error, 1)
80+ go func() { secondDone <- d.renameTab(sess, sess.tabs[0], "second") }()
81+ require.Eventually(t, func() bool {
82+ sess.mu.Lock()
83+ defer sess.mu.Unlock()
84+ return sess.tabs[0].name == "second"
85+ }, testWaitTimeout, time.Millisecond, "later mutation did not proceed while storage was blocked")
86+
87+ close(blocking.releaseFirst)
88+ firstResult := <-firstDone
89+ if tt.firstErr == nil {
90+ require.NoError(t, firstResult)
91+ } else {
92+ require.ErrorIs(t, firstResult, tt.firstErr, "an in-memory revision cannot subsume a failed durable write")
93+ }
94+ require.NoError(t, <-secondDone)
95+
96+ persisted, ok, err := catalogue.Record("work")
97+ require.NoError(t, err)
98+ require.True(t, ok)
99+ require.Equal(t, []string{"second"}, persisted.TabNames)
100+ require.Equal(t, "second", sess.tabs[0].name)
101+ })
102+ }
103+}
104+
105+func TestTwoConsecutiveMetadataFailuresAreBothReported(t *testing.T) {
106+ firstFailure := errors.New("first metadata write failed")
107+ secondFailure := errors.New("second metadata write failed")
108+ sess := newSnapshotTestSession(t, "work", false, "/work")
109+ catalogue, blocking := newBlockingCatalogue(sess, firstFailure)
110+ blocking.secondErr = secondFailure
111+ close(blocking.releaseFirst)
112+ d := newTestDaemon(t, nil, stubClock{})
113+ d.catalogue = blocking
114+ d.sessions[sess.id] = sess
115+
116+ require.ErrorIs(t, d.renameTab(sess, sess.tabs[0], "first"), firstFailure)
117+ require.ErrorIs(t, d.renameTab(sess, sess.tabs[0], "second"), secondFailure)
118+
119+ persisted, ok, err := catalogue.Record("work")
120+ require.NoError(t, err)
121+ require.True(t, ok)
122+ require.Empty(t, persisted.TabNames)
123+ sess.mu.Lock()
124+ require.Empty(t, sess.tabs[0].name)
125+ sess.mu.Unlock()
126+}
127+
128+func TestConsecutiveMetadataFailuresRestoreDurableState(t *testing.T) {
129+ firstFailure := errors.New("first metadata write failed")
130+ secondFailure := errors.New("second metadata write failed")
131+ sess := newSnapshotTestSession(t, "work", false, "/work")
132+ catalogue, blocking := newBlockingCatalogue(sess, firstFailure)
133+ blocking.secondErr = secondFailure
134+ d := newTestDaemon(t, nil, stubClock{})
135+ d.catalogue = blocking
136+ d.sessions[sess.id] = sess
137+
138+ firstDone := make(chan error, 1)
139+ go func() { firstDone <- d.renameTab(sess, sess.tabs[0], "first") }()
140+ select {
141+ case <-blocking.firstStarted:
142+ case <-time.After(testWaitTimeout):
143+ t.Fatal("first metadata write did not block")
144+ }
145+ secondDone := make(chan error, 1)
146+ go func() { secondDone <- d.renameTab(sess, sess.tabs[0], "second") }()
147+ require.Eventually(t, func() bool {
148+ sess.mu.Lock()
149+ defer sess.mu.Unlock()
150+ return sess.tabs[0].name == "second"
151+ }, testWaitTimeout, time.Millisecond)
152+
153+ close(blocking.releaseFirst)
154+ require.ErrorIs(t, <-firstDone, firstFailure)
155+ require.ErrorIs(t, <-secondDone, secondFailure)
156+
157+ persisted, ok, err := catalogue.Record("work")
158+ require.NoError(t, err)
159+ require.True(t, ok)
160+ require.Empty(t, persisted.TabNames)
161+ sess.mu.Lock()
162+ require.Empty(t, sess.tabs[0].name)
163+ sess.mu.Unlock()
164+}
165+
166+func TestNewerMetadataFailureDoesNotSkipOlderSnapshot(t *testing.T) {
167+ failure := errors.New("newer metadata write failed")
168+ sess := newSnapshotTestSession(t, "work", false, "/work")
169+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{sess.persistRecordLocked(sess.createdAt)})
170+ blocking := &blockingMetadataCatalogue{
171+ Catalogue: catalogue,
172+ firstStarted: make(chan struct{}),
173+ releaseFirst: make(chan struct{}),
174+ firstErr: failure,
175+ }
176+ close(blocking.releaseFirst)
177+ d := newTestDaemon(t, nil, stubClock{})
178+ d.catalogue = blocking
179+ d.sessions[sess.id] = sess
180+ tb := sess.tabs[0]
181+
182+ sess.mu.Lock()
183+ tb.name = "first"
184+ firstRecord, firstVersion := sess.nextPersistRecordLocked(sess.createdAt + 1)
185+ tb.name = "second"
186+ secondRecord, secondVersion := sess.nextPersistRecordLocked(sess.createdAt + 2)
187+ sess.mu.Unlock()
188+
189+ secondRollback := func() bool {
190+ sess.mu.Lock()
191+ defer sess.mu.Unlock()
192+ if tb.name != "second" {
193+ return false
194+ }
195+ tb.name = "first"
196+ return true
197+ }
198+ firstRollback := func() bool {
199+ sess.mu.Lock()
200+ defer sess.mu.Unlock()
201+ if tb.name != "first" {
202+ return false
203+ }
204+ tb.name = ""
205+ return true
206+ }
207+
208+ require.ErrorIs(t, d.persistSessionMetadata(sess, secondVersion, secondRecord.MetadataUpdate(), secondRollback), failure)
209+ require.NoError(t, d.persistSessionMetadata(sess, firstVersion, firstRecord.MetadataUpdate(), firstRollback))
210+
211+ persisted, ok, err := catalogue.Record("work")
212+ require.NoError(t, err)
213+ require.True(t, ok)
214+ require.Equal(t, []string{"first"}, persisted.TabNames)
215+ sess.mu.Lock()
216+ require.Equal(t, "first", tb.name)
217+ sess.mu.Unlock()
218+}
219+
220+func TestLatestMetadataFailureRollsBackRename(t *testing.T) {
221+ failure := errors.New("metadata write failed")
222+ sess := newSnapshotTestSession(t, "work", false, "/work")
223+ catalogue, blocking := newBlockingCatalogue(sess, failure)
224+ d := newTestDaemon(t, nil, stubClock{})
225+ d.catalogue = blocking
226+ d.sessions[sess.id] = sess
227+
228+ done := make(chan error, 1)
229+ go func() { done <- d.renameTab(sess, sess.tabs[0], "failed") }()
230+ select {
231+ case <-blocking.firstStarted:
232+ case <-time.After(testWaitTimeout):
233+ t.Fatal("metadata write did not block")
234+ }
235+ close(blocking.releaseFirst)
236+ require.ErrorIs(t, <-done, failure)
237+
238+ persisted, ok, err := catalogue.Record("work")
239+ require.NoError(t, err)
240+ require.True(t, ok)
241+ require.Empty(t, persisted.TabNames)
242+ require.Empty(t, sess.tabs[0].name)
243+}
244+
245+func TestBlockedCwdWriteCannotOverwriteLaterTabMetadata(t *testing.T) {
246+ sess := newSnapshotTestSession(t, "work", false, "/work")
247+ catalogue, blocking := newBlockingCatalogue(sess, nil)
248+ d := newTestDaemon(t, nil, stubClock{})
249+ d.catalogue = blocking
250+ d.sessions[sess.id] = sess
251+ d.procCwd = func(int) (string, error) { return "/later-cwd", nil }
252+
253+ cwdDone := make(chan struct{})
254+ go func() {
255+ d.refreshSessionCwd(sess)
256+ close(cwdDone)
257+ }()
258+ select {
259+ case <-blocking.firstStarted:
260+ case <-time.After(testWaitTimeout):
261+ t.Fatal("cwd metadata write did not block")
262+ }
263+
264+ renameDone := make(chan error, 1)
265+ go func() { renameDone <- d.renameTab(sess, sess.tabs[0], "later-tab") }()
266+ require.Eventually(t, func() bool {
267+ sess.mu.Lock()
268+ defer sess.mu.Unlock()
269+ return sess.tabs[0].name == "later-tab"
270+ }, testWaitTimeout, time.Millisecond)
271+
272+ close(blocking.releaseFirst)
273+ select {
274+ case <-cwdDone:
275+ case <-time.After(testWaitTimeout):
276+ t.Fatal("cwd refresh deadlocked")
277+ }
278+ require.NoError(t, <-renameDone)
279+
280+ persisted, ok, err := catalogue.Record("work")
281+ require.NoError(t, err)
282+ require.True(t, ok)
283+ require.Equal(t, "/later-cwd", persisted.Cwd)
284+ require.Equal(t, []string{"later-tab"}, persisted.TabNames)
285+}
M internal/usecase/daemon/performance_benchmark_test.go +3 -3
@@ -638,7 +638,7 @@ func publishBenchmarkSnapshot(b testing.TB, fixture *performanceFixture, generat
638638
639639 func mutateBenchmarkVisible(fixture *performanceFixture, operation int) {
640640 fixture.activePane.mu.Lock()
641- fixture.activePane.screen.Write([]byte(fmt.Sprintf("\x1b[1;1Hvisible-%08d", operation)))
641+ fixture.activePane.screen.Write(fmt.Appendf(nil, "\x1b[1;1Hvisible-%08d", operation))
642642 fixture.activePane.mu.Unlock()
643643 }
644644
@@ -653,7 +653,7 @@ func mutateBenchmarkTail(fixture *performanceFixture, operation int) {
653653 }
654654
655655 func mutateBenchmarkSealedChunk(fixture *performanceFixture, operation int) {
656- for row := 0; row < 256; row++ {
656+ for row := range 256 {
657657 mutateBenchmarkTail(fixture, operation*256+row)
658658 }
659659 }
@@ -1065,7 +1065,7 @@ func newPerformanceFixtureWithCleanup(t testing.TB, config performanceConfig, re
10651065 liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
10661066 resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
10671067 }
1068- WithSnapshotRepository(fixture.snapshots, nil)(d)
1068+ WithSnapshotRepository(fixture.snapshots)(d)
10691069 d.startSnapshotEncodeWorker()
10701070 if registerCleanup {
10711071 t.Cleanup(d.stopSnapshotEncodeWorker)
M internal/usecase/daemon/picker_test.go +27 -34
@@ -25,26 +25,6 @@ import (
2525
2626 // --- test doubles -----------------------------------------------------------
2727
28-// failingDeleteStore is a ports.Store whose Delete always fails, used to
29-// exercise the picker's kill-target error path without disturbing the
30-// generated mock's default Delete expectation.
31-type failingDeleteStore struct{ err error }
32-
33-func (failingDeleteStore) Get([]byte) ([]byte, bool) { return nil, false }
34-func (failingDeleteStore) Set([]byte, []byte) error { return nil }
35-func (s failingDeleteStore) Batch(changes []ports.StoreChange) error {
36- for _, change := range changes {
37- if change.Delete {
38- return s.err
39- }
40- }
41- return nil
42-}
43-func (s failingDeleteStore) Delete([]byte) error { return s.err }
44-func (failingDeleteStore) Range(func(k, v []byte) bool) {}
45-func (failingDeleteStore) Sync() error { return nil }
46-func (failingDeleteStore) Close() error { return nil }
47-
4828 type refusingSnapshotDeleteRepository struct {
4929 noOpSnapshotRepository
5030 err error
@@ -277,8 +257,8 @@ func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
277257 d.mu.Lock()
278258 delete(d.stopped, record.Name)
279259 d.sessions[target.id] = target
260+ closeRuntimeRestoreDoneLocked(done)
280261 d.mu.Unlock()
281- closeRuntimeRestoreDone(done)
282262
283263 require.NoError(t, <-result)
284264 require.Same(t, target, ac.currentSession())
@@ -294,12 +274,12 @@ func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) {
294274 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
295275 coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil, nil)
296276 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
297- WithSnapshotRepository(repository, nil)(d)
277+ WithSnapshotRepository(repository)(d)
298278 WithCheckpointCoordinator(coordinator)(d)
299279 restoreCtx, cancelRestore := context.WithCancel(context.Background())
300280 restored := make(chan struct{})
301281 go func() {
302- d.restoreCatalogue(restoreCtx, catalogue.Records())
282+ d.restoreCatalogue(restoreCtx, mustDurableRecords(t, catalogue))
303283 close(restored)
304284 }()
305285 <-repository.started
@@ -1150,8 +1130,18 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t
11501130 d, from, ac, sends, releases := newRecentNavigationTestSessions(t)
11511131 defer releaseAll(releases)
11521132 cause := errors.New("snapshot delete refused")
1153- WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause}, nil)(d)
1133+ WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause})(d)
11541134 target := d.sessions[domain.SessionID("recent")]
1135+ store, _ := newMockStore(t)
1136+ WithStore(store)(d)
1137+ target.mu.Lock()
1138+ if target.incarnation == (domain.IncarnationID{}) {
1139+ target.incarnation = domain.IncarnationID{1}
1140+ }
1141+ record := target.persistRecordLocked(1)
1142+ target.mu.Unlock()
1143+ record.RecoveryState = domain.RecoveryFresh
1144+ require.NoError(t, d.catalogue.Create(record))
11551145
11561146 d.enterPicker(from, ac)
11571147 awaitFrame(t, sends, ports.MsgOutput)
@@ -1184,8 +1174,14 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
11841174 defer release()
11851175 d, from, ac, sends := newManualSessionWithPTYs(t, p)
11861176 cause := errors.New("delete failed")
1187- WithStore(failingDeleteStore{err: cause})(d)
1188- d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7}
1177+ store, state := newMockStore(t)
1178+ WithStore(store)(d)
1179+ record := domain.CatalogueRecord{Name: "stopped", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/stopped", CreatedAt: 7, RecoveryState: domain.RecoveryFresh}
1180+ require.NoError(t, d.catalogue.Create(record))
1181+ state.mu.Lock()
1182+ state.deleteErr = func(string) error { return cause }
1183+ state.mu.Unlock()
1184+ d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp/stopped", createdAt: 7, incarnation: record.IncarnationID, record: record}
11891185
11901186 d.enterPicker(from, ac)
11911187 awaitFrame(t, sends, ports.MsgOutput)
@@ -1202,12 +1198,9 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
12021198 toasts := awaitToastCount(t, ac, 1)
12031199 require.Equal(t, domain.NoticePersistDelete, toasts[0].Code)
12041200
1205- views, _ := d.pickerViews(from)
1206- var stillListed bool
1207- for _, v := range views {
1208- if v.Stopped && v.Name == "stopped" {
1209- stillListed = true
1210- }
1211- }
1212- require.True(t, stillListed, "entry must remain listed after refreshPicker when the delete failed")
1201+ d.mu.Lock()
1202+ stopped, retained := d.stopped["stopped"]
1203+ d.mu.Unlock()
1204+ require.True(t, retained, "failed deletion must retain the reserved name")
1205+ require.True(t, stopped.purging, "an uncertain deletion stays fenced from restore")
12131206 }
M internal/usecase/daemon/recovery_observability.go +6 -1
@@ -22,7 +22,12 @@ func (d *Daemon) logSessionDegraded(record domain.CatalogueRecord, reasonCode st
2222 func (d *Daemon) logStartupRecoveryCounts(restoring int) {
2323 var records []domain.CatalogueRecord
2424 if d.catalogue != nil {
25- records = d.catalogue.Records()
25+ var err error
26+ records, err = d.catalogue.Records()
27+ if err != nil {
28+ d.log.Error("daemon_startup_catalogue_read_failed", "err", err)
29+ return
30+ }
2631 } else {
2732 d.mu.Lock()
2833 records = make([]domain.CatalogueRecord, 0, len(d.stopped))
M internal/usecase/daemon/render_capture.go +37 -12
@@ -17,15 +17,21 @@ import (
1717 // capture/compose/emit transaction holds that attachment's sendMu, so these
1818 // buffers cannot be observed or reused before emission completes.
1919 type renderCaptureScratch struct {
20- state capturedRenderState
21- panes []capturedPaneRenderState
22- placements []layout.Placement
23- dividers []layout.Divider
24- statusTabs []statusTab
25- mru []recentSession
26- ranked []rankedRecent
27- titleIDs []layout.PaneID
28- receipts []damageReceipt
20+ state capturedRenderState
21+ panes []capturedPaneRenderState
22+ placements []layout.Placement
23+ dividers []layout.Divider
24+ layoutTab *tab
25+ layoutGeneration uint64
26+ layoutArea domain.Rect
27+ layoutFocus layout.PaneID
28+ layoutFingerprint string
29+ layoutValid bool
30+ statusTabs []statusTab
31+ mru []recentSession
32+ ranked []rankedRecent
33+ titleIDs []layout.PaneID
34+ receipts []damageReceipt
2935 }
3036
3137 type damageReceipt struct {
@@ -225,9 +231,28 @@ func capturePrimaryRenderState(
225231
226232 tb.mu.Lock()
227233 defer tb.mu.Unlock()
228- layoutSnap := solveTabLayoutLocked(tb)
229- scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
230- scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
234+ area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
235+ var focus layout.PaneID
236+ if tb.tree != nil {
237+ focus = tb.tree.Focus
238+ }
239+ layoutSnap := tabLayoutSnapshot{
240+ area: area, focus: focus, placements: scratch.placements,
241+ dividers: scratch.dividers, fingerprint: scratch.layoutFingerprint, ok: scratch.layoutValid,
242+ }
243+ if scratch.layoutTab != tb || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus {
244+ layoutSnap = solveTabLayoutLocked(tb)
245+ scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
246+ scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
247+ scratch.layoutTab = tb
248+ scratch.layoutGeneration = tb.layoutGeneration
249+ scratch.layoutArea = area
250+ scratch.layoutFocus = focus
251+ scratch.layoutFingerprint = layoutSnap.fingerprint
252+ scratch.layoutValid = layoutSnap.ok
253+ layoutSnap.placements = scratch.placements
254+ layoutSnap.dividers = scratch.dividers
255+ }
231256 state := &scratch.state
232257 *state = capturedRenderState{
233258 attachment: ac, lease: lease, reset: reset, bars: bars, theme: bars.theme,
M internal/usecase/daemon/session.go +263 -126
@@ -15,7 +15,6 @@ import (
1515 "time"
1616
1717 "github.com/bnema/vev/internal/domain"
18- "github.com/bnema/vev/internal/persist"
1918 "github.com/bnema/vev/internal/ports"
2019 "github.com/bnema/vev/internal/usecase/layout"
2120 )
@@ -47,22 +46,31 @@ type session struct {
4746 // tab, or pane locks. The owner performs teardown without holding teardownMu;
4847 // duplicate daemon-shutdown callers can therefore bound their ownership wait
4948 // with Serve's shared snapshot deadline.
50- teardownMu sync.Mutex
51- teardownActive bool
52- teardownDone chan struct{}
53- teardownWaiters uint
54- teardownChanged chan struct{}
55- lifecycleStopOnce sync.Once
56- mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state
57- themeMu sync.Mutex
58- tabs []*tab
59- active int
60- client *attachedClient
61- clipboardQueue []clipboardForward
62- clipboardWorkerRunning bool
63- renameInProgress bool
64- cwd string
65- terminal terminalEnv
49+ teardownMu sync.Mutex
50+ teardownActive bool
51+ teardownDone chan struct{}
52+ teardownWaiters uint
53+ teardownChanged chan struct{}
54+ lifecycleStopOnce sync.Once
55+ mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state
56+ // metadataPersistMu serializes authority writes and post-I/O rollback. State
57+ // mutation paths must release d.mu and mu before acquiring it. After durable
58+ // I/O completes it may acquire d.mu then mu to reconcile failed revisions.
59+ metadataPersistMu sync.Mutex
60+ metadataVersion uint64 // guarded by mu; every durable metadata snapshot receives a revision
61+ metadataDurableVersion uint64 // guarded by metadataPersistMu
62+ metadataLiveVersion uint64 // rollback cursor guarded by metadataPersistMu
63+ metadataFailedRollbacks map[uint64]func() bool // guarded by metadataPersistMu
64+ themeMu sync.Mutex
65+ tabs []*tab
66+ active int
67+ client *attachedClient
68+ clipboardQueue []clipboardForward
69+ clipboardWorkerRunning bool
70+ renameInProgress bool
71+ renameDone chan struct{}
72+ cwd string
73+ terminal terminalEnv
6674 // env is the authoritative immutable environment snapshot for future PTY children.
6775 // It is guarded by mu and is always copied on ingress and egress.
6876 env []string
@@ -167,12 +175,14 @@ func (d *Daemon) touchMRU(sess *session) {
167175 }
168176 seq := d.mruSeq.Add(1)
169177 updated := false
178+ var previous uint64
170179 for {
171180 old := sess.mruAt.Load()
172181 if old >= seq {
173182 return
174183 }
175184 if sess.mruAt.CompareAndSwap(old, seq) {
185+ previous = old
176186 updated = true
177187 break
178188 }
@@ -182,19 +192,49 @@ func (d *Daemon) touchMRU(sess *session) {
182192 }
183193 sess.mu.Lock()
184194 name := sess.name
185- incarnation := sess.incarnation
186195 ephemeral := sess.ephemeral
196+ var record domain.CatalogueRecord
197+ var version uint64
198+ if !ephemeral {
199+ record, version = sess.nextPersistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
200+ }
187201 sess.mu.Unlock()
188202 if !ephemeral {
189- if err := d.updateCatalogueMetadata(domain.CatalogueMetadataUpdate{Name: name, IncarnationID: incarnation, LastUsedSeq: &seq}); err != nil {
203+ rollback := func() bool {
204+ return sess.mruAt.CompareAndSwap(seq, previous)
205+ }
206+ if err := d.persistSessionMetadata(sess, version, record.MetadataUpdate(), rollback); err != nil {
190207 d.log.Warn("touching persisted session recency failed", "err", err, "session", name)
191208 }
192209 }
193210 }
194211
195212 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
213+ // Historical focused tests call this helper directly. Acquire mu for those
214+ // callers while preserving the production contract that an owning caller
215+ // receives it still locked on return.
216+ acquiredMu := d.mu.TryLock()
217+ if acquiredMu {
218+ defer d.mu.Unlock()
219+ }
196220 env = copyEnvironment(env)
221+ if _, reserved := d.creating[name]; reserved {
222+ return nil, errSessionNameInUse
223+ }
224+ d.creating[name] = struct{}{}
225+ defer delete(d.creating, name)
197226 stopped, resuming := d.stopped[name]
227+ var authoritative domain.CatalogueRecord
228+ var authoritativeExists bool
229+ if !ephemeral && resuming && d.persistEnabled {
230+ d.mu.Unlock()
231+ var err error
232+ authoritative, authoritativeExists, err = d.catalogueRecord(name)
233+ d.mu.Lock()
234+ if err != nil {
235+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't read session catalogue", err)
236+ }
237+ }
198238 var createdAt int64
199239 var incarnation domain.IncarnationID
200240 if !ephemeral {
@@ -203,9 +243,9 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
203243 // the persisted identity rather than receive a fresh timestamp.
204244 createdAt = stopped.createdAt
205245 incarnation = stopped.incarnation
206- if authoritative, ok := d.catalogueRecord(name); ok {
207- incarnation = authoritative.IncarnationID
246+ if authoritativeExists {
208247 createdAt = authoritative.CreatedAt
248+ incarnation = authoritative.IncarnationID
209249 }
210250 if createdAt > d.lastAllocatedCreatedAt {
211251 d.lastAllocatedCreatedAt = createdAt
@@ -263,6 +303,9 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
263303 if !ephemeral && resuming {
264304 lastUsedSeq = stopped.lastUsedSeq
265305 }
306+ if lastUsedSeq == 0 {
307+ lastUsedSeq = d.mruSeq.Add(1)
308+ }
266309 sess := &session{
267310 id: id,
268311 name: name,
@@ -286,34 +329,50 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
286329 sess.snapEligible.Store(!ephemeral && name != "")
287330 if !ephemeral {
288331 if d.persistEnabled {
289- record := persist.Record{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names, RecoveryState: domain.RecoveryFresh}
332+ record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names, RecoveryState: domain.RecoveryFresh}
333+ if d.lifecycleRecovery == nil {
334+ closeTabs(tabs)
335+ cancel()
336+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("durable session authority is not configured"))
337+ }
338+ // The name reservation remains visible while durable authority I/O runs,
339+ // but daemon/session mutexes are released so storage cannot block routing.
340+ d.mu.Unlock()
290341 var err error
291- if resuming {
292- if _, ok := d.catalogueRecord(name); ok {
293- err = d.updateCatalogueMetadata(record.MetadataUpdate())
294- } else if d.lifecycleRecovery != nil {
295- record, err = d.lifecycleRecovery.Create(context.Background(), record)
296- } else {
297- err = d.createCatalogueRecord(record)
298- }
299- } else if d.lifecycleRecovery != nil {
300- record, err = d.lifecycleRecovery.Create(context.Background(), record)
342+ if resuming && authoritativeExists {
343+ err = d.updateCatalogueMetadata(record.MetadataUpdate())
301344 } else {
302- err = d.createCatalogueRecord(record)
345+ record, err = d.lifecycleRecovery.Create(d.serveCtx, record)
303346 }
347+ d.mu.Lock()
304348 if err != nil {
305349 closeTabs(tabs)
306350 cancel()
307351 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
308352 }
353+ if d.closing {
354+ // Creation lost its race with shutdown. Roll back a newly committed
355+ // authority record without holding mu; resumed metadata remains owned
356+ // by the existing stopped lifecycle.
357+ if !resuming {
358+ d.mu.Unlock()
359+ rollbackCtx, rollbackCancel := context.WithTimeout(context.WithoutCancel(d.serveCtx), snapshotFinalFlushTimeout)
360+ rollbackErr := d.lifecycleRecovery.Delete(rollbackCtx, name)
361+ rollbackCancel()
362+ d.mu.Lock()
363+ if rollbackErr != nil {
364+ d.log.Warn("rolling back session creation during shutdown failed", "session", name, "err", rollbackErr)
365+ }
366+ }
367+ closeTabs(tabs)
368+ cancel()
369+ return nil, errors.New("daemon is shutting down")
370+ }
309371 sess.incarnation = record.IncarnationID
310372 }
311373 delete(d.stopped, name)
312374 }
313375 d.sessions[id] = sess
314- if lastUsedSeq == 0 {
315- d.touchMRU(sess)
316- }
317376 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
318377 for i, tb := range tabs {
319378 d.log.Info("tab created", "session", name, "tab", i)
@@ -445,25 +504,41 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
445504 sess.tabs = append(sess.tabs, tb)
446505 sess.active = len(sess.tabs) - 1
447506 tabIndex := sess.active
448- record := sess.persistRecordLocked(time.Now().UnixNano())
507+ record, metadataVersion := sess.nextPersistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
449508 ephemeral := sess.ephemeral
509+ sess.mu.Unlock()
510+ d.mu.Unlock()
450511 if !ephemeral {
451- if err := d.updateCatalogueMetadata(record.MetadataUpdate()); err != nil {
452- sess.tabs = sess.tabs[:len(sess.tabs)-1]
453- sess.active = oldActive
512+ rollback := func() bool {
513+ d.mu.Lock()
514+ defer d.mu.Unlock()
515+ if d.sessions[sess.id] != sess {
516+ return true
517+ }
518+ sess.mu.Lock()
519+ if !slices.Contains(sess.tabs, tb) {
520+ sess.mu.Unlock()
521+ return true
522+ }
523+ if sess.tabs[len(sess.tabs)-1] != tb {
524+ sess.mu.Unlock()
525+ return false
526+ }
527+ sess.tabs = slices.DeleteFunc(sess.tabs, func(candidate *tab) bool { return candidate == tb })
528+ sess.active = min(oldActive, len(sess.tabs)-1)
529+ sess.mu.Unlock()
454530 if tb.cancel != nil {
455531 tb.cancel()
456532 }
457- sess.mu.Unlock()
458- d.mu.Unlock()
459533 _ = pty.Close()
534+ return true
535+ }
536+ if err := d.persistSessionMetadata(sess, metadataVersion, record.MetadataUpdate(), rollback); err != nil {
460537 return err
461538 }
462539 }
463- sess.mu.Unlock()
464540 d.log.Info("tab created", "session", name, "tab", tabIndex)
465541 d.startTabGoroutines(sess, tb)
466- d.mu.Unlock()
467542 d.activateTab(sess, tb)
468543 markSnapshotDirty(sess)
469544 return nil
@@ -686,51 +761,43 @@ func (d *Daemon) renameSession(sess *session, name string) error {
686761 }
687762 }
688763 lastUsedSeq := sess.mruAt.Load()
689- record := sess.persistRecordLocked(d.nowUnixNano())
764+ record := sess.persistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
690765 record.Name = name
691766 record.CreatedAt = createdAt
692767 record.LastUsedSeq = lastUsedSeq
693768 sess.renameInProgress = true
769+ sess.renameDone = make(chan struct{})
694770 sess.mu.Unlock()
695771 d.mu.Unlock()
696772
697773 rollback := func(err error) error {
698774 sess.mu.Lock()
699775 sess.renameInProgress = false
776+ if sess.renameDone != nil {
777+ close(sess.renameDone)
778+ sess.renameDone = nil
779+ }
700780 sess.mu.Unlock()
701781 return err
702782 }
703783 // Durable named renames are one atomic catalogue batch. The incarnation-keyed
704784 // snapshot namespace is intentionally untouched.
705785 if (wasEphemeral || oldName != name) && d.persistEnabled {
706- if d.lifecycleRecovery != nil {
786+ if d.lifecycleRecovery == nil {
787+ return rollback(errors.New("durable session authority is not configured"))
788+ }
789+ {
707790 var committed domain.CatalogueRecord
708791 var err error
709792 if wasEphemeral {
710- committed, err = d.lifecycleRecovery.Create(context.Background(), record)
793+ committed, err = d.lifecycleRecovery.Create(d.serveCtx, record)
711794 } else {
712- committed, err = d.lifecycleRecovery.Rename(context.Background(), oldName, name)
795+ committed, err = d.lifecycleRecovery.Rename(d.serveCtx, oldName, name)
713796 }
714797 if err != nil {
715798 return rollback(err)
716799 }
717800 record = committed
718- } else if wasEphemeral {
719- if err := d.createCatalogueRecord(record); err != nil {
720- return rollback(err)
721- }
722- } else {
723- if current, ok := d.catalogueRecord(oldName); ok {
724- current.Name = name
725- current.Cwd = record.Cwd
726- current.UpdatedAt = record.UpdatedAt
727- current.LastUsedSeq = record.LastUsedSeq
728- current.TabNames = append([]string(nil), record.TabNames...)
729- record = current
730- }
731- if err := d.catalogue.Rename(oldName, record); err != nil {
732- return rollback(err)
733- }
734801 }
735802 }
736803
@@ -745,6 +812,10 @@ func (d *Daemon) renameSession(sess *session, name string) error {
745812 sess.incarnation = record.IncarnationID
746813 sess.ephemeral = false
747814 sess.renameInProgress = false
815+ if sess.renameDone != nil {
816+ close(sess.renameDone)
817+ sess.renameDone = nil
818+ }
748819 sess.snapEligible.Store(name != "")
749820 sess.mu.Unlock()
750821 d.mu.Unlock()
@@ -774,22 +845,42 @@ func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
774845 }
775846 oldName := tb.name
776847 tb.name = name
777- record := sess.persistRecordLocked(time.Now().UnixNano())
848+ record, metadataVersion := sess.nextPersistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
778849 ephemeral := sess.ephemeral
779- if !ephemeral {
780- if err := d.updateCatalogueMetadata(record.MetadataUpdate()); err != nil {
781- tb.name = oldName
782- sess.mu.Unlock()
783- d.mu.Unlock()
784- return err
785- }
786- }
787850 sess.mu.Unlock()
788851 d.mu.Unlock()
852+ if ephemeral {
853+ return nil
854+ }
855+ rollback := func() bool {
856+ d.mu.Lock()
857+ defer d.mu.Unlock()
858+ if d.sessions[sess.id] != sess {
859+ return true
860+ }
861+ sess.mu.Lock()
862+ defer sess.mu.Unlock()
863+ if !slices.Contains(sess.tabs, tb) {
864+ return true
865+ }
866+ if tb.name != name {
867+ return false
868+ }
869+ tb.name = oldName
870+ return true
871+ }
872+ if err := d.persistSessionMetadata(sess, metadataVersion, record.MetadataUpdate(), rollback); err != nil {
873+ return err
874+ }
789875 return nil
790876 }
791877
792-func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
878+func (s *session) nextPersistRecordLocked(updatedAt int64) (domain.CatalogueRecord, uint64) {
879+ s.metadataVersion++
880+ return s.persistRecordLocked(updatedAt), s.metadataVersion
881+}
882+
883+func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord {
793884 createdAt := s.createdAt
794885 tabNames := make([]string, len(s.tabs))
795886 lastCustom := -1
@@ -804,7 +895,7 @@ func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
804895 } else {
805896 tabNames = tabNames[:lastCustom+1]
806897 }
807- return persist.Record{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames, RecoveryState: domain.RecoveryFresh}
898+ 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}
808899 }
809900
810901 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
@@ -843,8 +934,10 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
843934 return nil
844935 }
845936 ringing := tb.attention
937+ oldActive := sess.active
846938 wasActive := idx == sess.active
847939 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
940+ tabsAfterClose := len(sess.tabs)
848941 if sess.active >= len(sess.tabs) {
849942 sess.active = len(sess.tabs) - 1
850943 } else if idx < sess.active {
@@ -853,15 +946,34 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
853946 destination := sess.tabs[sess.active]
854947 ac := sess.client
855948 name := sess.name
856- record := sess.persistRecordLocked(time.Now().UnixNano())
949+ record, metadataVersion := sess.nextPersistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
857950 ephemeral := sess.ephemeral
951+ sess.mu.Unlock()
952+ d.mu.Unlock()
858953 if !ephemeral {
859- if err := d.updateCatalogueMetadata(record.MetadataUpdate()); err != nil {
954+ rollback := func() bool {
955+ d.mu.Lock()
956+ defer d.mu.Unlock()
957+ if d.sessions[sess.id] != sess {
958+ return true
959+ }
960+ sess.mu.Lock()
961+ defer sess.mu.Unlock()
962+ if slices.Contains(sess.tabs, tb) {
963+ return true
964+ }
965+ if len(sess.tabs) != tabsAfterClose {
966+ return false
967+ }
968+ sess.tabs = slices.Insert(sess.tabs, min(idx, len(sess.tabs)), tb)
969+ sess.active = min(oldActive, len(sess.tabs)-1)
970+ return true
971+ }
972+ if err := d.persistSessionMetadata(sess, metadataVersion, record.MetadataUpdate(), rollback); err != nil {
860973 d.log.Warn("persisting closed tab failed", "err", err, "session", name)
974+ return err
861975 }
862976 }
863- sess.mu.Unlock()
864- d.mu.Unlock()
865977 d.log.Info("tab closed", "session", name)
866978 markSnapshotDirty(sess)
867979
@@ -900,60 +1012,48 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
9001012 // beginSnapshotPurge commits a logical named-session kill before its live
9011013 // identity or persisted metadata is removed. It never runs under daemon or
9021014 // session locks.
903-func (d *Daemon) beginSnapshotPurge(name string, incarnation domain.IncarnationID) error {
904- if d.lifecycleRecovery != nil {
905- // The lifecycle coordinator performs the complete ordered protocol after
906- // snapshot writers have joined.
907- return nil
908- }
909- if d.snapshotRepository == nil || name == "" {
910- return nil
1015+func (d *Daemon) beginSnapshotPurge(_ string, _ domain.IncarnationID) error {
1016+ if d.persistEnabled && d.lifecycleRecovery == nil {
1017+ return errors.New("durable session authority is not configured")
9111018 }
912- return d.snapshotRepository.WriteDeletionTombstone(context.Background(), domain.DeletionTombstone{Name: name, IncarnationID: incarnation})
1019+ // The lifecycle coordinator performs the complete ordered protocol after
1020+ // snapshot writers have joined.
1021+ return nil
9131022 }
9141023
9151024 // finishSnapshotPurge deletes both independently durable snapshot sources,
9161025 // then metadata, and only then clears the tombstone. Any failure deliberately
9171026 // leaves the marker in place so startup cannot restore or import the name and
9181027 // a later live/offline kill can retry the idempotent source deletes.
919-func (d *Daemon) finishSnapshotPurge(name string, incarnation domain.IncarnationID) error {
920- if d.lifecycleRecovery != nil {
921- return d.lifecycleRecovery.Delete(context.Background(), name)
922- }
923- if d.snapshotRepository == nil {
924- return d.deleteCatalogueRecord(name)
1028+func (d *Daemon) finishSnapshotPurge(ctx context.Context, name string, _ domain.IncarnationID) error {
1029+ if !d.persistEnabled {
1030+ return nil
9251031 }
926- if err := d.snapshotRepository.QuarantineDeletionSources(context.Background(), domain.DeletionTombstone{Name: name, IncarnationID: incarnation}, false); err != nil {
927- return err
1032+ if d.lifecycleRecovery == nil {
1033+ return errors.New("durable session authority is not configured")
9281034 }
929- if err := d.deleteCatalogueRecord(name); err != nil {
930- return err
1035+ if ctx == nil {
1036+ ctx = d.serveCtx
9311037 }
932- return d.snapshotRepository.DeleteDeletionTombstone(context.Background(), incarnation)
1038+ purgeCtx, cancel := context.WithTimeout(ctx, snapshotFinalFlushTimeout)
1039+ defer cancel()
1040+ return d.lifecycleRecovery.Delete(purgeCtx, name)
9331041 }
9341042
9351043 // retryStoppedPurge completes a previously closed session's durable purge.
9361044 // It keeps the stopped record hidden until all sources and metadata cross their
9371045 // deletion boundaries.
9381046 func (d *Daemon) retryStoppedPurge(name string) error {
1047+ return d.retryStoppedPurgeContext(d.serveCtx, name)
1048+}
1049+
1050+func (d *Daemon) retryStoppedPurgeContext(ctx context.Context, name string) error {
9391051 d.mu.Lock()
9401052 stopped, ok := d.stopped[name]
9411053 if !ok {
9421054 d.mu.Unlock()
9431055 return nil
9441056 }
945- // Without a repository there is no restore/import source to fence, so the
946- // stopped record retains the metadata-only deletion behavior.
947- if d.snapshotRepository == nil {
948- d.mu.Unlock()
949- if err := d.deleteCatalogueRecord(name); err != nil {
950- return err
951- }
952- d.mu.Lock()
953- delete(d.stopped, name)
954- d.mu.Unlock()
955- return nil
956- }
9571057 stopped.purging = true
9581058 d.stopped[name] = stopped
9591059 d.mu.Unlock()
@@ -961,7 +1061,7 @@ func (d *Daemon) retryStoppedPurge(name string) error {
9611061 if err := d.beginSnapshotPurge(name, stopped.incarnation); err != nil {
9621062 return err
9631063 }
964- if err := d.finishSnapshotPurge(name, stopped.incarnation); err != nil {
1064+ if err := d.finishSnapshotPurge(ctx, name, stopped.incarnation); err != nil {
9651065 return err
9661066 }
9671067 d.mu.Lock()
@@ -1089,6 +1189,23 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
10891189 }
10901190 defer sess.finishTeardown()
10911191
1192+ // A rename reserves durable identity while coordinator I/O runs unlocked.
1193+ // Teardown waits for that reservation before selecting the purge name.
1194+ sess.mu.Lock()
1195+ renameDone := sess.renameDone
1196+ sess.mu.Unlock()
1197+ if renameDone != nil {
1198+ if deadline == nil {
1199+ <-renameDone
1200+ } else {
1201+ select {
1202+ case <-renameDone:
1203+ case <-deadline.Done():
1204+ return context.DeadlineExceeded
1205+ }
1206+ }
1207+ }
1208+
10921209 d.mu.Lock()
10931210 current := d.sessions[sess.id]
10941211 d.mu.Unlock()
@@ -1106,13 +1223,14 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
11061223 sess.mu.Lock()
11071224 isEphemeral := sess.ephemeral
11081225 name := sess.name
1226+ incarnation := sess.incarnation
11091227 sess.mu.Unlock()
11101228 // Commit the tombstone before changing live identity or metadata. A
11111229 // repository delete must never race an older publication that could recreate
11121230 // the just-quarantined name. Do not hold daemon or session locks while
11131231 // tombstoning or joining: a repository implementation may block on I/O.
11141232 if purge && !isEphemeral {
1115- if err := d.beginSnapshotPurge(name, sess.incarnation); err != nil {
1233+ if err := d.beginSnapshotPurge(name, incarnation); err != nil {
11161234 return err
11171235 }
11181236 if d.snapshotRepository != nil {
@@ -1188,7 +1306,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
11881306 ephemeral := sess.ephemeral
11891307 sess.mu.Unlock()
11901308 if !ephemeral {
1191- stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: sess.incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
1309+ stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
11921310 d.stopped[stoppedName] = stopped
11931311 }
11941312 empty := len(d.sessions) == 0
@@ -1248,7 +1366,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
12481366 // tombstone and hidden stopped record when any source or metadata operation
12491367 // fails; retryStoppedPurge (including a repeated live kill) resumes safely.
12501368 if !ephemeral && purge {
1251- if err := d.finishSnapshotPurge(stoppedName, sess.incarnation); err != nil {
1369+ if err := d.finishSnapshotPurge(d.serveCtx, stoppedName, incarnation); err != nil {
12521370 purgeErr = errors.Join(purgeErr, err)
12531371 d.log.Warn("finishing session snapshot purge failed", "err", err, "session", stoppedName)
12541372 } else {
@@ -1302,8 +1420,11 @@ func (d *Daemon) nameLiveOrStoppedLocked(name string) bool {
13021420 if d.findByNameLocked(name) != nil {
13031421 return true
13041422 }
1305- _, ok := d.stopped[name]
1306- return ok
1423+ if _, ok := d.stopped[name]; ok {
1424+ return true
1425+ }
1426+ _, reserved := d.creating[name]
1427+ return reserved
13071428 }
13081429
13091430 func (d *Daemon) cwdSampler(ctx context.Context) {
@@ -1363,19 +1484,38 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
13631484 d.mu.Unlock()
13641485 return
13651486 }
1487+ oldCwd := sess.cwd
13661488 sess.cwd = cwd
13671489 name := sess.name
1368- incarnation := sess.incarnation
13691490 ephemeral := sess.ephemeral
1491+ var record domain.CatalogueRecord
1492+ var metadataVersion uint64
1493+ if !ephemeral {
1494+ record, metadataVersion = sess.nextPersistRecordLocked(max(d.nowUnixNano(), sess.createdAt, int64(1)))
1495+ }
13701496 sess.mu.Unlock()
1497+ d.mu.Unlock()
13711498 if !ephemeral {
1372- updatedAt := time.Now().UnixNano()
1373- if err := d.updateCatalogueMetadata(domain.CatalogueMetadataUpdate{Name: name, IncarnationID: incarnation, Cwd: &cwd, UpdatedAt: &updatedAt}); err != nil {
1499+ rollback := func() bool {
1500+ d.mu.Lock()
1501+ defer d.mu.Unlock()
1502+ if d.sessions[sess.id] != sess {
1503+ return true
1504+ }
1505+ sess.mu.Lock()
1506+ defer sess.mu.Unlock()
1507+ if sess.cwd != cwd {
1508+ return false
1509+ }
1510+ sess.cwd = oldCwd
1511+ return true
1512+ }
1513+ if err := d.persistSessionMetadata(sess, metadataVersion, record.MetadataUpdate(), rollback); err != nil {
13741514 d.log.Warn("touching persisted session cwd failed", "err", err, "session", name)
1515+ return
13751516 }
13761517 markSnapshotDirty(sess)
13771518 }
1378- d.mu.Unlock()
13791519 }
13801520
13811521 // childEnv retains the daemon-environment helper for daemon-local legacy callers.
@@ -1389,11 +1529,8 @@ func copyEnvironment(env []string) []string {
13891529 }
13901530
13911531 func environmentEntry(entry string) (name, value string, ok bool) {
1392- i := strings.IndexByte(entry, '=')
1393- if i < 0 {
1394- return "", "", false
1395- }
1396- return entry[:i], entry[i+1:], true
1532+ name, value, ok = strings.Cut(entry, "=")
1533+ return name, value, ok
13971534 }
13981535
13991536 func (d *Daemon) ptyCommand(env []string) (string, []string) {
M internal/usecase/daemon/session_purge_control_test.go +5 -20
@@ -4,40 +4,25 @@ import (
44 "errors"
55 "testing"
66
7- "github.com/stretchr/testify/mock"
87 "github.com/stretchr/testify/require"
98
10- "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/domain"
1110 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1211 )
1312
14-func TestHandleKillStoppedUsesDurableBothSourcePurge(t *testing.T) {
15- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
16- repository := &retryablePurgeRepository{legacyErr: errors.New("legacy delete failed")}
17- WithSnapshotRepository(repository, repository)(d)
18- d.stopped["work"] = stoppedSession{name: "work"}
19-
20- transport := portsmocks.NewMockTransport(t)
21- transport.EXPECT().Send(mock.Anything).Return(nil).Once()
22- transport.EXPECT().Close().Return(nil).Once()
23- d.handleKill(transport, ports.Frame{Type: ports.MsgKill, Payload: ports.MarshalKill(ports.Kill{Name: "work"})})
24-
25- require.Equal(t, []string{"tombstone", "incremental", "legacy"}, repository.calls)
26- require.True(t, repository.tombstoned["work"])
27- require.True(t, d.stopped["work"].purging)
28-}
29-
3013 func TestStoppedPurgeMetadataFailureRemainsFencedForRetry(t *testing.T) {
3114 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
3215 repository := &retryablePurgeRepository{}
33- WithSnapshotRepository(repository, repository)(d)
16+ WithSnapshotRepository(repository)(d)
3417 store, state := newMockStore(t)
3518 WithStore(store)(d)
19+ record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh}
20+ require.NoError(t, d.catalogue.Create(record))
3621 metadataErr := errors.New("metadata delete failed")
3722 state.mu.Lock()
3823 state.deleteErr = func(string) error { return metadataErr }
3924 state.mu.Unlock()
40- d.stopped["work"] = stoppedSession{name: "work"}
25+ d.stopped["work"] = stoppedSession{name: "work", incarnation: record.IncarnationID, record: record}
4126
4227 require.ErrorIs(t, d.retryStoppedPurge("work"), metadataErr)
4328 require.True(t, repository.tombstoned["work"])
M internal/usecase/daemon/session_purge_retry_test.go +20 -4
@@ -18,12 +18,15 @@ type retryablePurgeRepository struct {
1818 legacyErr error
1919 tombstoned map[string]bool
2020 calls []string
21+ deadlineScoped []bool
2122 }
2223
2324 func (r *retryablePurgeRepository) Publish(context.Context, ports.SnapshotPublication) error {
2425 return nil
2526 }
26-func (r *retryablePurgeRepository) WriteDeletionTombstone(_ context.Context, tombstone domain.DeletionTombstone) error {
27+func (r *retryablePurgeRepository) WriteDeletionTombstone(ctx context.Context, tombstone domain.DeletionTombstone) error {
28+ _, hasDeadline := ctx.Deadline()
29+ r.deadlineScoped = append(r.deadlineScoped, hasDeadline)
2730 if r.tombstoned == nil {
2831 r.tombstoned = make(map[string]bool)
2932 }
@@ -31,14 +34,18 @@ func (r *retryablePurgeRepository) WriteDeletionTombstone(_ context.Context, tom
3134 r.tombstoned[tombstone.Name] = true
3235 return nil
3336 }
34-func (r *retryablePurgeRepository) QuarantineDeletionSources(_ context.Context, tombstone domain.DeletionTombstone, _ bool) error {
37+func (r *retryablePurgeRepository) QuarantineDeletionSources(ctx context.Context, tombstone domain.DeletionTombstone, _ bool) error {
38+ _, hasDeadline := ctx.Deadline()
39+ r.deadlineScoped = append(r.deadlineScoped, hasDeadline)
3540 r.calls = append(r.calls, "incremental", "legacy")
3641 if r.incrementalErr != nil {
3742 return r.incrementalErr
3843 }
3944 return r.legacyErr
4045 }
41-func (r *retryablePurgeRepository) DeleteDeletionTombstone(_ context.Context, _ domain.IncarnationID) error {
46+func (r *retryablePurgeRepository) DeleteDeletionTombstone(ctx context.Context, _ domain.IncarnationID) error {
47+ _, hasDeadline := ctx.Deadline()
48+ r.deadlineScoped = append(r.deadlineScoped, hasDeadline)
4249 r.calls = append(r.calls, "clear tombstone")
4350 for name := range r.tombstoned {
4451 delete(r.tombstoned, name)
@@ -65,8 +72,13 @@ func TestLivePurgeRetainsTombstoneAcrossPartialSourceDeletion(t *testing.T) {
6572 t.Run(tt.name, func(t *testing.T) {
6673 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
6774 repository := &retryablePurgeRepository{incrementalErr: tt.incremental, legacyErr: tt.legacy}
68- WithSnapshotRepository(repository, repository)(d)
75+ WithSnapshotRepository(repository)(d)
76+ store, _ := newMockStore(t)
77+ WithStore(store)(d)
6978 sess := newSnapshotTestSession(t, "work", false, "/work")
79+ record := sess.persistRecordLocked(1)
80+ record.RecoveryState = domain.RecoveryFresh
81+ require.NoError(t, d.catalogue.Create(record))
7082 pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
7183 require.True(t, ok, "snapshot test pane must use MockPTY")
7284 pty.EXPECT().Close().Return(nil).Once()
@@ -86,6 +98,10 @@ func TestLivePurgeRetainsTombstoneAcrossPartialSourceDeletion(t *testing.T) {
8698 require.NoError(t, d.retryStoppedPurge("work"))
8799 require.False(t, repository.tombstoned["work"])
88100 require.Equal(t, []string{"tombstone", "incremental", "legacy", "tombstone", "incremental", "legacy", "clear tombstone"}, repository.calls)
101+ require.NotEmpty(t, repository.deadlineScoped)
102+ for _, scoped := range repository.deadlineScoped {
103+ require.True(t, scoped, "every purge repository call must receive a deadline-scoped context")
104+ }
89105 d.mu.Lock()
90106 _, ok = d.stopped["work"]
91107 d.mu.Unlock()
M internal/usecase/daemon/session_rename_purge_test.go +26 -29
@@ -1,43 +1,40 @@
11 package daemon
22
33 import (
4+ "errors"
45 "testing"
56
67 "github.com/stretchr/testify/require"
78
89 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/persist"
911 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1012 )
1113
1214 func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {
13- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
14- repository := &retryablePurgeRepository{}
15- WithSnapshotRepository(repository, repository)(d)
16- store, state := newMockStore(t)
17- WithStore(store)(d)
18- sess := newSnapshotTestSession(t, "old", false, "/work")
19- d.sessions = map[domain.SessionID]*session{sess.id: sess}
20- require.NoError(t, d.persist.Save(sess.persistRecordLocked(1)))
15+ legacyDeleteErr := errors.New("legacy delete failed")
16+ for _, tt := range []struct {
17+ name string
18+ legacyErr error
19+ }{
20+ {name: "snapshot sources available"},
21+ {name: "unrelated legacy source delete would fail", legacyErr: legacyDeleteErr},
22+ } {
23+ t.Run(tt.name, func(t *testing.T) {
24+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
25+ repository := &retryablePurgeRepository{legacyErr: tt.legacyErr}
26+ WithSnapshotRepository(repository)(d)
27+ store, state := newMockStore(t)
28+ WithStore(store)(d)
29+ sess := newSnapshotTestSession(t, "old", false, "/work")
30+ d.sessions = map[domain.SessionID]*session{sess.id: sess}
31+ require.NoError(t, d.catalogue.(*persist.Persister).Save(sess.persistRecordLocked(1)))
2132
22- require.NoError(t, d.renameSession(sess, "new"))
23- require.Empty(t, repository.calls, "rename must not touch incarnation-keyed snapshot storage")
24- require.False(t, state.has("old"))
25- require.True(t, state.has("new"))
26- require.Equal(t, sess.incarnation, state.record(t, "new").IncarnationID)
27-}
28-
29-func TestRenameIgnoresUnrelatedLegacySourceFailure(t *testing.T) {
30- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
31- repository := &retryablePurgeRepository{}
32- WithSnapshotRepository(repository, repository)(d)
33- store, state := newMockStore(t)
34- WithStore(store)(d)
35- sess := newSnapshotTestSession(t, "old", false, "/work")
36- d.sessions = map[domain.SessionID]*session{sess.id: sess}
37- require.NoError(t, d.persist.Save(sess.persistRecordLocked(1)))
38-
39- require.NoError(t, d.renameSession(sess, "new"))
40- require.Empty(t, repository.calls)
41- require.False(t, state.has("old"))
42- require.True(t, state.has("new"))
33+ require.NoError(t, d.renameSession(sess, "new"))
34+ require.Empty(t, repository.calls, "rename must not invoke source deletion, including a failing legacy source")
35+ require.False(t, state.has("old"))
36+ require.True(t, state.has("new"))
37+ require.Equal(t, sess.incarnation, state.record(t, "new").IncarnationID)
38+ })
39+ }
4340 }
M internal/usecase/daemon/session_test.go +54 -28
@@ -806,11 +806,27 @@ func (s newStaticStore) Get(key []byte) ([]byte, bool) {
806806 func (s newStaticStore) Set(_, _ []byte) error { return nil }
807807 func (s newStaticStore) Delete(_ []byte) error { return nil }
808808 func (s newStaticStore) Batch(changes []ports.StoreChange) error {
809- for _, change := range changes {
809+ if len(changes) == 0 {
810+ return errors.New("empty batch")
811+ }
812+ prepared := make([]ports.StoreChange, len(changes))
813+ seen := make(map[string]struct{}, len(changes))
814+ for i, change := range changes {
815+ key := string(change.Key)
816+ if _, exists := seen[key]; exists {
817+ return errors.New("duplicate batch key")
818+ }
819+ if change.Delete && len(change.Value) != 0 {
820+ return errors.New("delete batch change has value")
821+ }
822+ seen[key] = struct{}{}
823+ prepared[i] = ports.StoreChange{Key: append([]byte(nil), change.Key...), Value: append([]byte(nil), change.Value...), Delete: change.Delete}
824+ }
825+ for _, change := range prepared {
810826 if change.Delete {
811827 delete(s, string(change.Key))
812828 } else {
813- s[string(change.Key)] = append([]byte(nil), change.Value...)
829+ s[string(change.Key)] = change.Value
814830 }
815831 }
816832 return nil
@@ -841,19 +857,26 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
841857 store, _ := newMockStore(t)
842858 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
843859 incarnation := domain.IncarnationID{1}
844- require.NoError(t, seed.persist.Save(persist.Record{Name: "work", IncarnationID: incarnation, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9, RecoveryState: domain.RecoveryFresh}))
860+ require.NoError(t, seed.catalogue.(*persist.Persister).Save(persist.Record{Name: "work", IncarnationID: incarnation, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9, RecoveryState: domain.RecoveryFresh}))
845861
846862 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
847863 d.mu.Lock()
848864 stopped := d.stopped["work"]
849865 d.mu.Unlock()
850- require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, incarnation: incarnation, lastUsedSeq: 9}, stopped)
866+ require.Equal(t, "work", stopped.name)
867+ require.Equal(t, "/tmp/work", stopped.cwd)
868+ require.Equal(t, int64(7), stopped.createdAt)
869+ require.Equal(t, incarnation, stopped.incarnation)
870+ require.Equal(t, uint64(9), stopped.lastUsedSeq)
871+ require.Equal(t, domain.RecoveryFresh, stopped.record.RecoveryState)
872+ require.Equal(t, runtimeFresh, stopped.state)
873+ require.NotNil(t, stopped.restoreDone)
851874 require.Equal(t, uint64(9), d.mruSeq.Load())
852875 }
853876
854877 // TestDaemonNewWithoutPersistenceLogsNoLoadWarning covers the default,
855878 // persistence-free construction path (no WithStore/WithCatalogue option):
856-// d.persist is the persist.New(nil) sentinel, so LoadAll now returns
879+// d.catalogue.(*persist.Persister) is the persist.New(nil) sentinel, so LoadAll now returns
857880 // errPersistenceUnavailable instead of an empty slice. That must not surface
858881 // as a "loading persisted sessions failed" warning, since nothing failed —
859882 // persistence simply was never configured.
@@ -866,20 +889,6 @@ func TestDaemonNewWithoutPersistenceLogsNoLoadWarning(t *testing.T) {
866889 require.NotContains(t, logBuf.String(), "loading persisted sessions failed")
867890 }
868891
869-// TestDaemonNewWithConfiguredStoreStillLogsLoadFailure is the inverse of the
870-// above: when persistence IS configured (a real store via WithStore) and the
871-// load genuinely fails (an undecodable catalogue record here), the warning
872-// must still fire.
873-func TestDaemonNewWithConfiguredStoreStillLogsLoadFailure(t *testing.T) {
874- store, state := newMockStore(t)
875- state.data["bad"] = []byte{0, 1} // not a valid encoded catalogue record
876- var logBuf strings.Builder
877- d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logBuf, nil)), WithStore(store))
878- require.NotNil(t, d)
879- require.True(t, d.persistEnabled)
880- require.Contains(t, logBuf.String(), "loading persisted sessions failed")
881-}
882-
883892 func TestTouchMRUConcurrentUpdatesRemainMonotonic(t *testing.T) {
884893 d := &Daemon{}
885894 sess := &session{}
@@ -930,7 +939,7 @@ func TestTouchMRUPersistsNamedButNotEphemeral(t *testing.T) {
930939 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
931940 WithStore(store)(d)
932941 named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1, incarnation: domain.IncarnationID{1}}
933- require.NoError(t, d.persist.Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryFresh}))
942+ require.NoError(t, d.catalogue.(*persist.Persister).Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryFresh}))
934943
935944 d.touchMRU(named)
936945 require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq)
@@ -1003,7 +1012,7 @@ func TestRenameTabPersistsForNamedSession(t *testing.T) {
10031012 require.NoError(t, err)
10041013 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
10051014
1006- records, err := d.persist.LoadAll()
1015+ records, err := d.catalogue.(*persist.Persister).LoadAll()
10071016 require.NoError(t, err)
10081017 require.Len(t, records, 1)
10091018 require.Equal(t, "work", records[0].Name)
@@ -1013,6 +1022,23 @@ func TestRenameTabPersistsForNamedSession(t *testing.T) {
10131022 require.Equal(t, []string{"shell"}, records[0].TabNames)
10141023 }
10151024
1025+func TestRenameTabDoesNotHoldDaemonOrSessionMutexDuringCatalogueIO(t *testing.T) {
1026+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
1027+ sess := newSnapshotTestSession(t, "work", false, "/work")
1028+ d.sessions[sess.id] = sess
1029+ catalogue := portsmocks.NewMockCatalogue(t)
1030+ WithCatalogue(catalogue, nil)(d)
1031+ catalogue.EXPECT().UpdateMetadata(mock.Anything).RunAndReturn(func(domain.CatalogueMetadataUpdate) error {
1032+ require.True(t, d.mu.TryLock(), "daemon mutex held during catalogue I/O")
1033+ d.mu.Unlock()
1034+ require.True(t, sess.mu.TryLock(), "session mutex held during catalogue I/O")
1035+ sess.mu.Unlock()
1036+ return nil
1037+ }).Once()
1038+
1039+ require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
1040+}
1041+
10161042 func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
10171043 tests := []struct {
10181044 name string
@@ -1045,7 +1071,7 @@ func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
10451071 }
10461072
10471073 require.NoError(t, d.closeTab(sess, sess.tabs[tt.closeIndex], false))
1048- records, err := d.persist.LoadAll()
1074+ records, err := d.catalogue.(*persist.Persister).LoadAll()
10491075 require.NoError(t, err)
10501076 require.Len(t, records, 1)
10511077 require.Equal(t, tt.want, records[0].TabNames)
@@ -1090,7 +1116,7 @@ func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
10901116 require.Equal(t, "shell", sess.tabs[0].name)
10911117
10921118 require.False(t, state.has("0"))
1093- records, err := d.persist.LoadAll()
1119+ records, err := d.catalogue.(*persist.Persister).LoadAll()
10941120 require.NoError(t, err)
10951121 require.Empty(t, records)
10961122 }
@@ -1104,7 +1130,7 @@ func TestAttachRestoresPersistedTabNames(t *testing.T) {
11041130 store, _ := newMockStore(t)
11051131 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
11061132 WithStore(store)(d)
1107- require.NoError(t, d.persist.Save(persist.Record{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}, RecoveryState: domain.RecoveryFresh}))
1133+ require.NoError(t, d.catalogue.(*persist.Persister).Save(persist.Record{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}, RecoveryState: domain.RecoveryFresh}))
11081134 d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
11091135 tr := portsmocks.NewMockTransport(t)
11101136 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
@@ -1213,8 +1239,8 @@ func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T)
12131239 defer release()
12141240 store := portsmocks.NewMockStore(t)
12151241 var attempted map[string][]byte
1216- store.EXPECT().Range(mock.Anything).Run(func(func([]byte, []byte) bool) {}).Twice()
1217- store.EXPECT().Get([]byte("named")).Return(nil, false).Twice()
1242+ store.EXPECT().Range(mock.Anything).Run(func(func([]byte, []byte) bool) {}).Times(3)
1243+ store.EXPECT().Get([]byte("named")).Return(nil, false).Times(4)
12181244 store.EXPECT().Batch(mock.Anything).RunAndReturn(func(changes []ports.StoreChange) error {
12191245 attempted = make(map[string][]byte, len(changes))
12201246 for _, change := range changes {
@@ -1318,7 +1344,7 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
13181344 store, state := newMockStore(t)
13191345 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
13201346 WithStore(store)(d)
1321- require.NoError(t, d.persist.Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryFresh}))
1347+ require.NoError(t, d.catalogue.(*persist.Persister).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryFresh}))
13221348 d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1}
13231349 require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true}))
13241350 require.False(t, state.has("old"))
@@ -1830,7 +1856,7 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {
18301856 func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) {
18311857 store, _ := newMockStore(t)
18321858 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
1833- require.NoError(t, seed.persist.Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900, RecoveryState: domain.RecoveryFresh}))
1859+ require.NoError(t, seed.catalogue.(*persist.Persister).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900, RecoveryState: domain.RecoveryFresh}))
18341860
18351861 p, release := newBlockingPTY(t)
18361862 defer release()
M internal/usecase/daemon/snapshot.go +6 -0
@@ -47,6 +47,9 @@ type snapshotCapture struct {
4747 publicationContext context.Context
4848 sealedRefs map[*vt.HistoryChunk]snapcodec.ObjectRef // set by the single encoder worker
4949 coordinatorDiscarded bool // guarded by session.snapshotMu
50+ // normalWorkerAdmitted is set before snapshotJobs admission and immutable
51+ // until completion; final-queue captures leave it false.
52+ normalWorkerAdmitted bool
5053 finishOnce sync.Once
5154 }
5255
@@ -98,3 +101,6 @@ const (
98101 snapshotInterval = 2 * time.Minute
99102 snapshotFinalFlushTimeout = time.Second
100103 )
104+
105+// SnapshotShutdownTimeout reports the shared final-checkpoint shutdown budget.
106+func SnapshotShutdownTimeout() time.Duration { return snapshotFinalFlushTimeout }
M internal/usecase/daemon/snapshot_coordinator.go +13 -0
@@ -108,6 +108,14 @@ func (d *Daemon) waitForSnapshotGenerationWithDeadline(sess *session, generation
108108 return d.waitForSnapshotGeneration(sess, generation)
109109 }
110110 for {
111+ // Once the shared deadline is observable, timeout wins even if publication
112+ // completion becomes ready concurrently. This preserves the timed-out
113+ // session identity and avoids nondeterministic success after the budget.
114+ select {
115+ case <-deadline.Done():
116+ return false
117+ default:
118+ }
111119 sess.snapshotMu.Lock()
112120 published := sess.snapshotPublishedMutationRevision >= generation
113121 changed := sess.snapshotChangeLocked()
@@ -207,6 +215,11 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool)
207215 capture.session.signalSnapshotChangedLocked()
208216 wake := capture.session.snapshotWake
209217 capture.session.snapshotMu.Unlock()
218+ if capture.normalWorkerAdmitted {
219+ d.snapshotWorkerMu.Lock()
220+ delete(d.snapshotAdmitted, capture)
221+ d.snapshotWorkerMu.Unlock()
222+ }
210223 if wake != nil {
211224 select {
212225 case wake <- struct{}{}:
M internal/usecase/daemon/snapshot_generation_test.go +2 -2
@@ -17,7 +17,7 @@ import (
1717 func TestSnapshotMultipleMutationsPublishNextRepositoryGeneration(t *testing.T) {
1818 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
1919 repository := portsmocks.NewMockSnapshotRepository(t)
20- WithSnapshotRepository(repository, nil)(d)
20+ WithSnapshotRepository(repository)(d)
2121 startSnapshotEncodeWorker(t, d)
2222
2323 publications := make(chan ports.SnapshotPublication, 1)
@@ -44,7 +44,7 @@ func TestSnapshotMultipleMutationsPublishNextRepositoryGeneration(t *testing.T)
4444 func TestSnapshotRetryKeepsRepositoryGeneration(t *testing.T) {
4545 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
4646 repository := portsmocks.NewMockSnapshotRepository(t)
47- WithSnapshotRepository(repository, nil)(d)
47+ WithSnapshotRepository(repository)(d)
4848 startSnapshotEncodeWorker(t, d)
4949
5050 publications := make(chan ports.SnapshotPublication, 2)
D internal/usecase/daemon/snapshot_import_test.go +0 -163
@@ -1,163 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "encoding/base64"
6- "errors"
7- "os"
8- "path/filepath"
9- "testing"
10-
11- "github.com/stretchr/testify/require"
12-
13- "github.com/bnema/vev/internal/ports"
14- snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
15-)
16-
17-type snapshotAcceptanceLegacySource struct {
18- blobs []ports.LegacySnapshot
19- loadCalls int
20- deleteCalls []string
21- deleteErr error
22-}
23-
24-func (s *snapshotAcceptanceLegacySource) LoadLegacy(ctx context.Context) ([]ports.LegacySnapshot, error) {
25- if err := ctx.Err(); err != nil {
26- return nil, err
27- }
28- s.loadCalls++
29- out := make([]ports.LegacySnapshot, len(s.blobs))
30- for i, blob := range s.blobs {
31- out[i] = ports.LegacySnapshot{Name: blob.Name, Data: append([]byte(nil), blob.Data...)}
32- }
33- return out, nil
34-}
35-
36-func (s *snapshotAcceptanceLegacySource) DeleteVerifiedLegacy(ctx context.Context, blob ports.LegacySnapshot) error {
37- return s.DeleteLegacy(ctx, blob.Name)
38-}
39-
40-func (s *snapshotAcceptanceLegacySource) DeleteLegacy(ctx context.Context, name string) error {
41- if err := ctx.Err(); err != nil {
42- return err
43- }
44- s.deleteCalls = append(s.deleteCalls, name)
45- if s.deleteErr != nil {
46- return s.deleteErr
47- }
48- for i, blob := range s.blobs {
49- if blob.Name == name {
50- s.blobs = append(s.blobs[:i], s.blobs[i+1:]...)
51- break
52- }
53- }
54- return nil
55-}
56-
57-func legacyAcceptanceSnapshot(t *testing.T) snapcodec.Session {
58- t.Helper()
59- data, err := os.ReadFile(filepath.Join("testdata", "legacy-v3-empty.b64"))
60- require.NoError(t, err)
61- encoded, err := base64.StdEncoding.DecodeString(string(data))
62- require.NoError(t, err)
63- snapshot, err := snapcodec.Unmarshal(encoded)
64- require.NoError(t, err)
65- return snapshot
66-}
67-
68-func legacyAcceptanceBlob(t *testing.T, snapshot snapcodec.Session) ports.LegacySnapshot {
69- t.Helper()
70- fixture := legacyAcceptanceSnapshot(t)
71- require.Equal(t, fixture, snapshot, "legacy fixture must be the imported session")
72- data, err := os.ReadFile(filepath.Join("testdata", "legacy-v3-empty.b64"))
73- require.NoError(t, err)
74- encoded, err := base64.StdEncoding.DecodeString(string(data))
75- require.NoError(t, err)
76- return ports.LegacySnapshot{Name: snapshot.Name, Data: encoded}
77-}
78-
79-func TestLegacyPublicationMarshalObjectErrorsIdentifyPaneAndStage(t *testing.T) {
80- for _, tt := range []struct {
81- name string
82- mutate func(*snapcodec.Pane)
83- stage string
84- }{
85- {name: "sealed chunk", mutate: func(pane *snapcodec.Pane) { pane.SealedChunks = [][]byte{nil} }, stage: "sealed chunk 0"},
86- {name: "tail", mutate: func(pane *snapcodec.Pane) { pane.Tail = nil }, stage: "tail"},
87- {name: "visible", mutate: func(pane *snapcodec.Pane) { pane.Visible = nil }, stage: "visible"},
88- } {
89- t.Run(tt.name, func(t *testing.T) {
90- pane := snapcodec.Pane{ID: "pane-1", StableID: "pane-stable", Tail: []byte("tail"), Visible: []byte("visible")}
91- tt.mutate(&pane)
92- snapshot := snapcodec.Session{Name: "legacy", Tabs: []snapcodec.Tab{{StableID: "tab-1", Panes: []snapcodec.Pane{pane}}}}
93-
94- _, err := legacyPublication(snapshot)
95- require.Error(t, err)
96- require.ErrorContains(t, err, `tab "tab-1"`)
97- require.ErrorContains(t, err, `pane "pane-1"`)
98- require.ErrorContains(t, err, tt.stage)
99- require.ErrorContains(t, err, "marshal object")
100- })
101- }
102-}
103-
104-func TestImportLegacySnapshotsIsRetiredRuntimeNoop(t *testing.T) {
105- snapshot := legacyAcceptanceSnapshot(t)
106- repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration)}
107- legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}
108- d := newTestDaemon(t, nil, stubClock{})
109- WithSnapshotRepository(repository, legacy)(d)
110-
111- d.importLegacySnapshots(context.Background())
112-
113- // Migration is adapter-owned. The daemon must never name-scan, publish, or
114- // delete a legacy source during normal recovery.
115- require.Empty(t, repository.publishes)
116- require.Empty(t, legacy.deleteCalls)
117- require.Len(t, legacy.blobs, 1)
118-}
119-
120-func TestImportLegacyExistingIncrementalSkipsLegacy(t *testing.T) {
121- snapshot := legacyAcceptanceSnapshot(t)
122- repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: make(map[string]ports.SnapshotGeneration)}
123- legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}
124- d := newTestDaemon(t, nil, stubClock{})
125- WithSnapshotRepository(repository, legacy)(d)
126-
127- d.importLegacySnapshots(context.Background())
128-
129- require.Empty(t, repository.publishes)
130- require.Empty(t, legacy.deleteCalls)
131- require.Len(t, legacy.blobs, 1)
132-}
133-
134-func TestImportLegacyRetainsSourceUntilExactReloadVerification(t *testing.T) {
135- snapshot := legacyAcceptanceSnapshot(t)
136- for _, test := range []struct {
137- name string
138- publish error
139- load error
140- mutate func(ports.SnapshotGeneration, ports.SnapshotPublication) ports.SnapshotGeneration
141- }{
142- {"publish failure", errors.New("publish failed"), nil, nil},
143- {"reload failure", nil, errors.New("reload failed"), nil},
144- {"manifest metadata mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration {
145- g.Generation++
146- return g
147- }},
148- {"manifest bytes mismatch", nil, nil, func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration {
149- g.Manifest[len(g.Manifest)-1] ^= 1
150- return g
151- }},
152- } {
153- t.Run(test.name, func(t *testing.T) {
154- repository := &snapshotAcceptanceRepository{generations: make(map[string]ports.SnapshotGeneration), publishErr: test.publish, loadErr: test.load, loadMutate: test.mutate}
155- legacy := &snapshotAcceptanceLegacySource{blobs: []ports.LegacySnapshot{legacyAcceptanceBlob(t, snapshot)}}
156- d := newTestDaemon(t, nil, stubClock{})
157- WithSnapshotRepository(repository, legacy)(d)
158- d.importLegacySnapshots(context.Background())
159- require.Empty(t, legacy.deleteCalls)
160- require.Len(t, legacy.blobs, 1)
161- })
162- }
163-}
M internal/usecase/daemon/snapshot_queue.go +6 -0
@@ -107,10 +107,16 @@ func (d *Daemon) enqueueSnapshotCapture(capture *snapshotCapture) bool {
107107 if d.snapshotWorkerClosing || d.snapshotWorkerCancel == nil || d.snapshotWorkerCtx == nil || d.snapshotWorkerCtx.Err() != nil {
108108 return false
109109 }
110+ capture.normalWorkerAdmitted = true
110111 select {
111112 case d.snapshotJobs <- capture:
113+ if d.snapshotAdmitted == nil {
114+ d.snapshotAdmitted = make(map[*snapshotCapture]struct{})
115+ }
116+ d.snapshotAdmitted[capture] = struct{}{}
112117 return true
113118 default:
119+ capture.normalWorkerAdmitted = false
114120 return false
115121 }
116122 }
M internal/usecase/daemon/snapshot_restore.go +23 -6
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55 "crypto/rand"
6+ "errors"
67 "fmt"
78 "math"
89
@@ -13,6 +14,20 @@ import (
1314 )
1415
1516 func (d *Daemon) closeRestoreDone() {
17+ d.mu.Lock()
18+ for name, entry := range d.stopped {
19+ if entry.state != runtimeRestoring {
20+ continue
21+ }
22+ entry.state = runtimeDegraded
23+ entry.record.RecoveryState = domain.RecoveryDegraded
24+ if entry.record.DegradedReason == "" {
25+ entry.record.DegradedReason = "restore unavailable"
26+ }
27+ d.stopped[name] = entry
28+ closeRuntimeRestoreDoneLocked(entry.restoreDone)
29+ }
30+ d.mu.Unlock()
1631 d.restoreOnce.Do(func() { close(d.restoreDone) })
1732 }
1833
@@ -293,13 +308,15 @@ func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *se
293308 }
294309 }
295310 }
296- // An existing catalogue checkpoint already authorizes this runtime. Only a
297- // legacy snapshot without a catalogue record creates fresh metadata here.
311+ // Restoration is catalogue-authorized. Missing or unreadable authority is a
312+ // hard failure; migration creates catalogue records before daemon startup.
298313 if d.persistEnabled {
299- if _, ok := d.catalogueRecord(sess.name); !ok {
300- if err := d.createCatalogueRecord(sess.persistRecordLocked(sess.createdAt)); err != nil {
301- return false, err
302- }
314+ _, ok, err := d.catalogueRecord(sess.name)
315+ if err != nil {
316+ return false, err
317+ }
318+ if !ok {
319+ return false, errors.New("snapshot: restored session is absent from catalogue")
303320 }
304321 }
305322 d.mu.Lock()
R internal/usecase/daemon/snapshot_legacy_import.go → internal/usecase/daemon/snapshot_restore_fixture_test.go +2 -3 renamed, 91% similar
@@ -8,9 +8,8 @@ import (
88 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
99 )
1010
11-func legacyPublication(snapshot snapcodec.Session) (ports.SnapshotPublication, error) {
12- // Legacy payloads have no durable identity; this test-only conversion uses
13- // the fixture's stable synthetic identity.
11+func acceptancePublication(snapshot snapcodec.Session) (ports.SnapshotPublication, error) {
12+ // acceptancePublication converts a restore fixture to repository form.
1413 incarnation := domain.IncarnationID{1}
1514 manifest := snapcodec.Manifest{Generation: 1, IncarnationID: incarnation, Name: snapshot.Name, CreatedAt: snapshot.CreatedAt, Active: snapshot.Active, Tabs: make([]snapcodec.ManifestTab, 0, len(snapshot.Tabs))}
1615 objects := make([]ports.SnapshotObject, 0)
M internal/usecase/daemon/snapshot_restore_incremental.go +53 -10
@@ -43,7 +43,12 @@ func (d *Daemon) restoreIncrementalSnapshots(ctx context.Context) {
4343 if d.catalogue == nil {
4444 return
4545 }
46- d.restoreCatalogue(ctx, d.catalogue.Records())
46+ records, err := d.catalogue.Records()
47+ if err != nil {
48+ d.log.Error("loading catalogue for snapshot restoration failed", "err", err)
49+ return
50+ }
51+ d.restoreCatalogue(ctx, records)
4752 }
4853
4954 // restoreCatalogue gives every expected record an independent bounded job.
@@ -95,7 +100,9 @@ func (d *Daemon) recordRestoreDone(name string) chan struct{} {
95100 return d.stopped[name].restoreDone
96101 }
97102
98-func closeRuntimeRestoreDone(done chan struct{}) {
103+// closeRuntimeRestoreDoneLocked closes a per-record restoration barrier.
104+// Caller must hold d.mu so state transition and waiter release are atomic.
105+func closeRuntimeRestoreDoneLocked(done chan struct{}) {
99106 if done == nil {
100107 return
101108 }
@@ -125,8 +132,13 @@ func (d *Daemon) setStoppedRecovery(record domain.CatalogueRecord, state runtime
125132
126133 func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr error, done chan struct{}) {
127134 if restoreErr != nil {
135+ catalogueReadable := true
128136 if d.catalogue != nil {
129- if current, ok := d.catalogue.Record(record.Name); ok && current.IncarnationID == record.IncarnationID {
137+ current, ok, readErr := d.catalogue.Record(record.Name)
138+ if readErr != nil {
139+ catalogueReadable = false
140+ d.log.Error("reading catalogue before degradation failed", "session", record.Name, "err", readErr)
141+ } else if ok && current.IncarnationID == record.IncarnationID {
130142 record = current
131143 }
132144 }
@@ -135,7 +147,7 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e
135147 if errors.Is(restoreErr, context.Canceled) || errors.Is(restoreErr, context.DeadlineExceeded) {
136148 record.DegradedReason = "restore interrupted"
137149 }
138- if d.catalogue != nil {
150+ if d.catalogue != nil && catalogueReadable {
139151 if err := d.catalogue.Replace(record.Name, record); err != nil {
140152 d.log.Error("marking session degraded failed", "session", record.Name, "err", err)
141153 }
@@ -155,7 +167,7 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e
155167 entry.tabNames = append([]string(nil), record.TabNames...)
156168 d.stopped[record.Name] = entry
157169 }
158- closeRuntimeRestoreDone(done)
170+ closeRuntimeRestoreDoneLocked(done)
159171 }
160172 d.mu.Unlock()
161173 if restoreErr != nil {
@@ -197,26 +209,42 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor
197209 var selectedGeneration ports.SnapshotGeneration
198210 var selectedSnapshot snapcodec.Session
199211 selectedIndex := -1
212+ selectedFallback := false
200213 for i, candidate := range candidates {
214+ fallback := record.Committed == nil || i > 0
201215 generation, err := d.snapshotRepository.LoadCheckpoint(ctx, record.IncarnationID, record.Name, candidate)
202216 if err != nil {
203217 if ctxErr := ctx.Err(); ctxErr != nil {
204218 return ctxErr
205219 }
220+ d.logRejectedRestoreCandidate(record, candidate, fallback, "load-failed", err)
206221 continue
207222 }
208- snapshot, err := sessionFromGeneration(generation)
209- if err != nil || generation.IncarnationID != record.IncarnationID || generation.Name != record.Name || generation.Generation != candidate.Generation {
223+ snapshot, decodeErr := sessionFromGeneration(generation)
224+ if decodeErr != nil {
225+ d.logRejectedRestoreCandidate(record, candidate, fallback, "decode-failed", decodeErr)
210226 continue
211227 }
212- selected, selectedGeneration, selectedSnapshot, selectedIndex = candidate, generation, snapshot, i
228+ if generation.IncarnationID != record.IncarnationID {
229+ d.logRejectedRestoreCandidate(record, candidate, fallback, "incarnation-mismatch", nil)
230+ continue
231+ }
232+ if generation.Name != record.Name {
233+ d.logRejectedRestoreCandidate(record, candidate, fallback, "name-mismatch", nil)
234+ continue
235+ }
236+ if generation.Generation != candidate.Generation {
237+ d.logRejectedRestoreCandidate(record, candidate, fallback, "generation-mismatch", nil)
238+ continue
239+ }
240+ selected, selectedGeneration, selectedSnapshot, selectedIndex, selectedFallback = candidate, generation, snapshot, i, fallback
213241 break
214242 }
215243 if selectedIndex < 0 {
216244 return errors.New("snapshot: no catalogue checkpoint validated")
217245 }
218246
219- if selectedIndex > 0 {
247+ if selectedFallback {
220248 if d.checkpointRecovery == nil {
221249 return errors.New("snapshot: checkpoint coordinator unavailable for fallback promotion")
222250 }
@@ -244,10 +272,25 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor
244272 return err
245273 }
246274 d.setStoppedRecovery(record, runtimeHealthy)
247- d.logSessionRestoreComplete(record, selected.Generation, selectedIndex > 0)
275+ d.logSessionRestoreComplete(record, selected.Generation, selectedFallback)
248276 return nil
249277 }
250278
279+func (d *Daemon) logRejectedRestoreCandidate(record domain.CatalogueRecord, candidate domain.CheckpointRef, fallback bool, reason string, err error) {
280+ kind := "committed"
281+ if fallback {
282+ kind = "fallback"
283+ }
284+ d.log.Warn("snapshot_restore_candidate_rejected",
285+ "session", record.Name,
286+ "incarnation", record.IncarnationID.String(),
287+ "generation", candidate.Generation,
288+ "candidate", kind,
289+ "reason_code", reason,
290+ "err", err,
291+ )
292+}
293+
251294 func checkpointCandidates(record domain.CatalogueRecord) []domain.CheckpointRef {
252295 candidates := make([]domain.CheckpointRef, 0, 3)
253296 if record.Committed != nil {
M internal/usecase/daemon/snapshot_restore_test.go +6 -7
@@ -175,7 +175,7 @@ func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session {
175175
176176 func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation uint64) ports.SnapshotGeneration {
177177 t.Helper()
178- publication, err := legacyPublication(snapshot)
178+ publication, err := acceptancePublication(snapshot)
179179 require.NoError(t, err)
180180 manifest, err := snapcodec.UnmarshalManifest(publication.Manifest)
181181 require.NoError(t, err)
@@ -218,13 +218,12 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
218218 repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}}
219219 pty, release := newBlockingPTY(t)
220220 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
221- store, _ := newMockStore(t)
222- WithStore(store)(d)
223221 checkpoint := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)}
224222 record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), RecoveryState: domain.RecoveryHealthy, Committed: &checkpoint}
225223 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
226224 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
227- WithSnapshotRepository(repository, nil)(d)
225+ WithSnapshotRepository(repository)(d)
226+ WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil, nil))(d)
228227 t.Cleanup(func() { release(); d.sessWg.Wait() })
229228
230229 d.restoreIncrementalSnapshots(context.Background())
@@ -277,7 +276,7 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
277276 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
278277 coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil, nil)
279278 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
280- WithSnapshotRepository(repository, nil)(d)
279+ WithSnapshotRepository(repository)(d)
281280 WithCheckpointCoordinator(coordinator)(d)
282281 t.Cleanup(func() { release(); d.sessWg.Wait() })
283282
@@ -291,7 +290,7 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
291290 updates := catalogue.MetadataUpdates()
292291 require.Len(t, updates, 1, "the authoritative Catalogue port must receive restored-session metadata updates")
293292 require.Equal(t, record.IncarnationID, updates[0].IncarnationID)
294- updated, ok := catalogue.Record(record.Name)
293+ updated, ok, _ := catalogue.Record(record.Name)
295294 require.True(t, ok)
296295 require.Equal(t, record.IncarnationID, updated.IncarnationID)
297296 require.Equal(t, record.RecoveryState, updated.RecoveryState)
@@ -310,7 +309,7 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
310309 require.Empty(t, failure)
311310 require.Len(t, repository.publishes, 1)
312311 require.Equal(t, record.Committed, repository.publishes[0].ParentCheckpoint)
313- published, ok := catalogue.Record(record.Name)
312+ published, ok, _ := catalogue.Record(record.Name)
314313 require.True(t, ok)
315314 require.Equal(t, uint64(10), published.Committed.Generation)
316315 require.Equal(t, committed, *published.Fallbacks[0])
M internal/usecase/daemon/snapshot_scheduler_test.go +27 -21
@@ -19,7 +19,7 @@ import (
1919 func TestSnapshotCoordinatorQuarantineCancelsPublicationBeforeDelete(t *testing.T) {
2020 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
2121 repository := portsmocks.NewMockSnapshotRepository(t)
22- WithSnapshotRepository(repository, nil)(d)
22+ WithSnapshotRepository(repository)(d)
2323 startSnapshotEncodeWorker(t, d)
2424
2525 started := make(chan struct{})
@@ -48,7 +48,7 @@ func TestSnapshotCoordinatorQuarantineCancelsPublicationBeforeDelete(t *testing.
4848 func TestSnapshotCoordinatorQuarantineJoinsInFlightPublication(t *testing.T) {
4949 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
5050 repository := portsmocks.NewMockSnapshotRepository(t)
51- WithSnapshotRepository(repository, nil)(d)
51+ WithSnapshotRepository(repository)(d)
5252 startSnapshotEncodeWorker(t, d)
5353
5454 started := make(chan struct{})
@@ -84,7 +84,7 @@ func TestSnapshotCoordinatorQuarantineJoinsInFlightPublication(t *testing.T) {
8484 func TestForcedSnapshotSchedulesSuccessorAfterRoutineInFlight(t *testing.T) {
8585 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
8686 repository := portsmocks.NewMockSnapshotRepository(t)
87- WithSnapshotRepository(repository, nil)(d)
87+ WithSnapshotRepository(repository)(d)
8888 startSnapshotEncodeWorker(t, d)
8989
9090 started := make(chan struct{})
@@ -123,7 +123,7 @@ func TestForcedSnapshotSchedulesSuccessorAfterRoutineInFlight(t *testing.T) {
123123 func TestForcedSnapshotSchedulesSuccessorForRoutineCaptureQueuedBehindWorker(t *testing.T) {
124124 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
125125 repository := portsmocks.NewMockSnapshotRepository(t)
126- WithSnapshotRepository(repository, nil)(d)
126+ WithSnapshotRepository(repository)(d)
127127 startSnapshotEncodeWorker(t, d)
128128
129129 blockerStarted := make(chan struct{})
@@ -164,7 +164,7 @@ func TestForcedSnapshotSchedulesSuccessorForRoutineCaptureQueuedBehindWorker(t *
164164 func TestMultipleForcedSnapshotsCoalesceToOneSuccessor(t *testing.T) {
165165 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
166166 repository := portsmocks.NewMockSnapshotRepository(t)
167- WithSnapshotRepository(repository, nil)(d)
167+ WithSnapshotRepository(repository)(d)
168168 startSnapshotEncodeWorker(t, d)
169169
170170 started := make(chan struct{})
@@ -207,7 +207,7 @@ func TestForcedSnapshotShutdownTimeoutRetainsRetryableStateAndNotice(t *testing.
207207 clock := newSnapshotDeadlineClock()
208208 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
209209 repository := portsmocks.NewMockSnapshotRepository(t)
210- WithSnapshotRepository(repository, nil)(d)
210+ WithSnapshotRepository(repository)(d)
211211 notices := portsmocks.NewMockNoticeStore(t)
212212 WithNoticeStore(notices)(d)
213213 startSnapshotEncodeWorker(t, d)
@@ -290,7 +290,7 @@ func TestServeShutdownDeadlineStillJoinsPaneExitWriter(t *testing.T) {
290290 clock := newServeShutdownClock()
291291 d := newTestDaemon(t, newFactory(t, pty), clock)
292292 repository := portsmocks.NewMockSnapshotRepository(t)
293- WithSnapshotRepository(repository, nil)(d)
293+ WithSnapshotRepository(repository)(d)
294294
295295 publicationStarted := make(chan struct{})
296296 releasePublication := make(chan struct{})
@@ -305,7 +305,7 @@ func TestServeShutdownDeadlineStillJoinsPaneExitWriter(t *testing.T) {
305305
306306 tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
307307 listener := serveSnapshotListener(t, tr)
308- ctx, cancel := context.WithCancel(context.Background())
308+ ctx, cancel := context.WithCancel(t.Context())
309309 defer cancel()
310310 served := make(chan error, 1)
311311 go func() { served <- d.Serve(ctx, listener) }()
@@ -382,7 +382,7 @@ func awaitTeardownWaiters(t *testing.T, sess *session, want uint) {
382382 func TestConcurrentSessionTeardownPublishesOneFinalSnapshot(t *testing.T) {
383383 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
384384 repository := portsmocks.NewMockSnapshotRepository(t)
385- WithSnapshotRepository(repository, nil)(d)
385+ WithSnapshotRepository(repository)(d)
386386 startSnapshotEncodeWorker(t, d)
387387
388388 publicationStarted := make(chan struct{})
@@ -456,7 +456,7 @@ func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) {
456456 defer releasePTY()
457457 d := newTestDaemon(t, newFactory(t, p), stubClock{})
458458 repository := portsmocks.NewMockSnapshotRepository(t)
459- WithSnapshotRepository(repository, nil)(d)
459+ WithSnapshotRepository(repository)(d)
460460
461461 published := make(chan ports.SnapshotPublication, 2)
462462 allowTerminalPublication := make(chan struct{})
@@ -470,7 +470,7 @@ func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) {
470470
471471 tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
472472 l := serveSnapshotListener(t, tr)
473- ctx, cancel := context.WithCancel(context.Background())
473+ ctx, cancel := context.WithCancel(t.Context())
474474 defer cancel()
475475 served := make(chan error, 1)
476476 go func() { served <- d.Serve(ctx, l) }()
@@ -500,14 +500,15 @@ func TestServeShutdownDeadlineStillJoinsUncooperativeSnapshotRepository(t *testi
500500 clock := newServeShutdownClock()
501501 d := newTestDaemon(t, newFactory(t, p), clock)
502502 repository := portsmocks.NewMockSnapshotRepository(t)
503- WithSnapshotRepository(repository, nil)(d)
503+ WithSnapshotRepository(repository)(d)
504504 noticeStore := portsmocks.NewMockNoticeStore(t)
505505 WithNoticeStore(noticeStore)(d)
506506 noticeStore.EXPECT().Claim().Return(nil, nil).Maybe()
507507 noticeStore.EXPECT().Ack().Return(nil).Maybe()
508+ noticePersisted := make(chan struct{})
508509 noticeStore.EXPECT().Append(mock.MatchedBy(func(n domain.Notification) bool {
509510 return n.Code == domain.NoticeSnapshotWrite && n.Severity == domain.NoticeError
510- })).Return(nil).Once()
511+ })).Run(func(domain.Notification) { close(noticePersisted) }).Return(nil).Once()
511512
512513 firstPublicationCompleted := make(chan struct{})
513514 terminalPublicationStarted := make(chan struct{})
@@ -526,7 +527,7 @@ func TestServeShutdownDeadlineStillJoinsUncooperativeSnapshotRepository(t *testi
526527
527528 tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
528529 l := serveSnapshotListener(t, tr)
529- ctx, cancel := context.WithCancel(context.Background())
530+ ctx, cancel := context.WithCancel(t.Context())
530531 defer cancel()
531532 served := make(chan error, 1)
532533 go func() { served <- d.Serve(ctx, l) }()
@@ -551,6 +552,11 @@ func TestServeShutdownDeadlineStillJoinsUncooperativeSnapshotRepository(t *testi
551552 d.snapshotWorkerMu.Unlock()
552553 require.NotNil(t, workerDone)
553554 clock.nextFinalTimer(t).fire()
555+ select {
556+ case <-noticePersisted:
557+ case <-time.After(testWaitTimeout):
558+ t.Fatal("shutdown timeout notice was not persisted before the writer join")
559+ }
554560
555561 select {
556562 case err := <-served:
@@ -632,7 +638,7 @@ func TestRoutineSnapshotEligibilityStartsAtCompletionAndForcedDoesNotMoveIt(t *t
632638 clock := &snapshotSchedulerClock{now: time.Unix(100, 0)}
633639 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
634640 repository := portsmocks.NewMockSnapshotRepository(t)
635- WithSnapshotRepository(repository, nil)(d)
641+ WithSnapshotRepository(repository)(d)
636642 startSnapshotEncodeWorker(t, d)
637643
638644 var publishes atomic.Int32
@@ -780,7 +786,7 @@ func TestSnapshotCompletionSchedulesRoutineRetriesFromCompletion(t *testing.T) {
780786 clock := newDeterministicSnapshotClock(now)
781787 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
782788 repository := portsmocks.NewMockSnapshotRepository(t)
783- WithSnapshotRepository(repository, nil)(d)
789+ WithSnapshotRepository(repository)(d)
784790
785791 for _, tt := range []struct {
786792 name string
@@ -813,12 +819,12 @@ func TestSnapshotSchedulerImmediateEligibilityAndStaleCapturesRemainDirty(t *tes
813819 now := time.Unix(100, 0)
814820 clock := newDeterministicSnapshotClock(now)
815821 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
816- WithSnapshotRepository(portsmocks.NewMockSnapshotRepository(t), nil)(d)
822+ WithSnapshotRepository(portsmocks.NewMockSnapshotRepository(t))(d)
817823
818824 t.Run("no prior attempt is immediately eligible", func(t *testing.T) {
819825 published := make(chan ports.SnapshotPublication, 1)
820826 repository := portsmocks.NewMockSnapshotRepository(t)
821- WithSnapshotRepository(repository, nil)(d)
827+ WithSnapshotRepository(repository)(d)
822828 startSnapshotEncodeWorker(t, d)
823829 repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
824830 published <- publication
@@ -873,7 +879,7 @@ func TestSnapshotWorkerQueueIsBoundedAndForcedCapturesCoalesce(t *testing.T) {
873879 clock := newDeterministicSnapshotClock(now)
874880 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
875881 repository := portsmocks.NewMockSnapshotRepository(t)
876- WithSnapshotRepository(repository, nil)(d)
882+ WithSnapshotRepository(repository)(d)
877883 startSnapshotEncodeWorker(t, d)
878884
879885 started := make(chan struct{})
@@ -931,7 +937,7 @@ func TestSnapshotRepositorySaverUsesEarliestDeadlineAndRecomputes(t *testing.T)
931937 clock := newDeterministicSnapshotClock(now)
932938 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
933939 repository := portsmocks.NewMockSnapshotRepository(t)
934- WithSnapshotRepository(repository, nil)(d)
940+ WithSnapshotRepository(repository)(d)
935941 startSnapshotEncodeWorker(t, d)
936942
937943 published := make(chan ports.SnapshotPublication, 1)
@@ -946,7 +952,7 @@ func TestSnapshotRepositorySaverUsesEarliestDeadlineAndRecomputes(t *testing.T)
946952 later.snapshotNextEligibleAt = now.Add(10 * time.Minute)
947953 d.sessions[later.id] = later
948954
949- ctx, cancel := context.WithCancel(context.Background())
955+ ctx, cancel := context.WithCancel(t.Context())
950956 done := make(chan struct{})
951957 go func() {
952958 d.snapshotRepositorySaver(ctx)
M internal/usecase/daemon/snapshot_test.go +39 -1
@@ -17,10 +17,45 @@ import (
1717
1818 // TestSnapshotWorkerPublishesContentAddressedCapture verifies that all new
1919 // checkpoints use the repository publication contract.
20+func TestWithSnapshotRepositoryRejectsTypedNil(t *testing.T) {
21+ var repository *snapshotAcceptanceRepository
22+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
23+
24+ WithSnapshotRepository(repository)(d)
25+
26+ require.Nil(t, d.snapshotRepository)
27+ require.False(t, d.snapsEnabled)
28+}
29+
30+func TestDurableWriterFailureNamesIncludesBufferedCapture(t *testing.T) {
31+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
32+ capture := &snapshotCapture{name: "work", session: newSnapshotTestSession(t, "work", false, "/work")}
33+ d.snapshotWorkerMu.Lock()
34+ d.snapshotAdmitted[capture] = struct{}{}
35+ d.snapshotJobs <- capture
36+ d.snapshotWorkerMu.Unlock()
37+
38+ require.Equal(t, []string{"work"}, d.durableWriterFailureNames())
39+}
40+
41+func TestCheckpointPublicationWithoutCommittedRefFailsCapture(t *testing.T) {
42+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
43+ WithSnapshotRepository(portsmocks.NewMockSnapshotRepository(t))(d)
44+ coordinator := portsmocks.NewMockCheckpointCoordinator(t)
45+ WithCheckpointCoordinator(coordinator)(d)
46+ startSnapshotEncodeWorker(t, d)
47+ coordinator.EXPECT().PublishCheckpoint(mock.Anything, "work", mock.Anything).Return(domain.CatalogueRecord{Name: "work"}, nil).Once()
48+
49+ sess := newSnapshotTestSession(t, "work", false, "/work")
50+ require.True(t, d.captureSession(sess))
51+ awaitSnapshotIdle(t, sess)
52+ require.True(t, sess.snapDirty.Load(), "nil committed publication must remain retryable")
53+}
54+
2055 func TestSnapshotWorkerPublishesContentAddressedCapture(t *testing.T) {
2156 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
2257 repository := portsmocks.NewMockSnapshotRepository(t)
23- WithSnapshotRepository(repository, nil)(d)
58+ WithSnapshotRepository(repository)(d)
2459 startSnapshotEncodeWorker(t, d)
2560
2661 repository.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(p ports.SnapshotPublication) bool {
@@ -57,6 +92,9 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
5792 // focused test sinks that only need to observe publication or deletion calls.
5893 type noOpSnapshotRepository struct{}
5994
95+var _ ports.SnapshotRepository = noOpSnapshotRepository{}
96+
97+func (noOpSnapshotRepository) Publish(context.Context, ports.SnapshotPublication) error { return nil }
6098 func (noOpSnapshotRepository) LoadCheckpoint(context.Context, domain.IncarnationID, string, ports.CheckpointRef) (ports.SnapshotGeneration, error) {
6199 return ports.SnapshotGeneration{}, errors.New("unused")
62100 }
M internal/usecase/daemon/snapshot_worker.go +18 -3
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "context"
5+ "errors"
56 "sort"
67
78 "github.com/bnema/vev/internal/domain"
@@ -134,8 +135,12 @@ func (d *Daemon) publishSnapshotCapture(workerCtx context.Context, workerID uint
134135 if d.checkpointRecovery != nil {
135136 var record domain.CatalogueRecord
136137 record, err = d.checkpointRecovery.PublishCheckpoint(publicationContext, capture.name, publication)
137- if err == nil && record.Committed != nil {
138- capture.checkpoint = *record.Committed
138+ if err == nil {
139+ if record.Committed == nil {
140+ err = errors.New("snapshot: checkpoint publication returned no committed checkpoint")
141+ } else {
142+ capture.checkpoint = *record.Committed
143+ }
139144 }
140145 } else {
141146 err = d.snapshotRepository.Publish(publicationContext, publication)
@@ -217,8 +222,12 @@ func (d *Daemon) StopDurableWriters(ctx context.Context) []string {
217222 case <-done:
218223 return nil
219224 case <-ctx.Done():
225+ // Snapshot identity before cancellation can let a cooperative worker clear
226+ // its in-flight capture. The caller persists timeout notices before the
227+ // unconditional ownership join.
228+ names := d.durableWriterFailureNames()
220229 cancel()
221- return d.durableWriterFailureNames()
230+ return names
222231 }
223232 }
224233
@@ -267,6 +276,9 @@ func (d *Daemon) requestDurableWriterStop() (context.CancelFunc, <-chan struct{}
267276 return cancel, d.snapshotWorkerDone
268277 }
269278
279+// durableWriterFailureNames includes admitted buffered captures as well as the
280+// active and final queues. snapshotAdmitted tracks normal captures from queue
281+// admission through completion, so worker dequeue cannot make one disappear.
270282 func (d *Daemon) durableWriterFailureNames() []string {
271283 d.snapshotWorkerMu.Lock()
272284 defer d.snapshotWorkerMu.Unlock()
@@ -280,6 +292,9 @@ func (d *Daemon) durableWriterFailureNames() []string {
280292 for _, capture := range d.snapshotFinalJobs {
281293 add(capture)
282294 }
295+ for capture := range d.snapshotAdmitted {
296+ add(capture)
297+ }
283298 names := make([]string, 0, len(seen))
284299 for name := range seen {
285300 names = append(names, name)
M internal/usecase/recovery/checkpoint.go +12 -15
@@ -37,7 +37,10 @@ func (c *Coordinator) PublishCheckpoint(ctx context.Context, name string, public
3737 unlock := c.locks.Lock([]string{name})
3838 defer unlock()
3939
40- record, ok := c.catalogue.Record(name)
40+ record, ok, err := c.catalogue.Record(name)
41+ if err != nil {
42+ return domain.CatalogueRecord{}, err
43+ }
4144 if !ok {
4245 return domain.CatalogueRecord{}, ErrCheckpointRecordNotFound
4346 }
@@ -74,7 +77,10 @@ func (c *Coordinator) PromoteFallback(ctx context.Context, name string, ref doma
7477 }
7578
7679 func (c *Coordinator) promoteFallbackLocked(ctx context.Context, name string, ref domain.CheckpointRef) (ports.FallbackPromotionOutcome, error) {
77- record, ok := c.catalogue.Record(name)
80+ record, ok, err := c.catalogue.Record(name)
81+ if err != nil {
82+ return ports.FallbackPromotionOutcome{}, err
83+ }
7884 if !ok {
7985 return ports.FallbackPromotionOutcome{}, ErrCheckpointRecordNotFound
8086 }
@@ -129,24 +135,15 @@ func (c *Coordinator) validateCheckpoint(ctx context.Context, record domain.Cata
129135 if err != nil {
130136 return err
131137 }
132- if generation.IncarnationID != record.IncarnationID || generation.Name != record.Name || generation.Generation != ref.Generation ||
133- sha256.Sum256(generation.Manifest) != ref.ManifestDigest {
134- return ErrCheckpointConflict
135- }
136- return nil
138+ return validateLoadedGeneration(record, ref, generation)
137139 }
138140
139-func equalCheckpointRefs(a, b *domain.CheckpointRef) bool {
140- if a == nil || b == nil {
141- return a == nil && b == nil
142- }
143- return *a == *b
144-}
141+func equalCheckpointRefs(a, b *domain.CheckpointRef) bool { return a.Equal(b) }
145142
146143 func copyCheckpointRef(ref *domain.CheckpointRef) *domain.CheckpointRef {
147144 if ref == nil {
148145 return nil
149146 }
150- copy := *ref
151- return &copy
147+ cloned := *ref
148+ return &cloned
152149 }
M internal/usecase/recovery/checkpoint_test.go +28 -26
@@ -20,13 +20,15 @@ type checkpointCatalogue struct {
2020 events *[]string
2121 }
2222
23-func (c *checkpointCatalogue) Records() []domain.CatalogueRecord {
24- return []domain.CatalogueRecord{c.record}
23+func (c *checkpointCatalogue) Records() ([]domain.CatalogueRecord, error) {
24+ c.mu.Lock()
25+ defer c.mu.Unlock()
26+ return []domain.CatalogueRecord{c.record}, nil
2527 }
26-func (c *checkpointCatalogue) Record(name string) (domain.CatalogueRecord, bool) {
28+func (c *checkpointCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
2729 c.mu.Lock()
2830 defer c.mu.Unlock()
29- return c.record, c.record.Name == name
31+ return c.record, c.record.Name == name, nil
3032 }
3133 func (c *checkpointCatalogue) Create(domain.CatalogueRecord) error { return nil }
3234 func (c *checkpointCatalogue) UpdateMetadata(domain.CatalogueMetadataUpdate) error {
@@ -127,33 +129,33 @@ func runCheckpointCommit(t *testing.T, count int) (domain.CatalogueRecord, *chec
127129 catalogue.record, err = coordinator.PublishCheckpoint(context.Background(), "work", checkpointPublication(catalogue.record, generation))
128130 require.NoError(t, err)
129131 }
130- for i := 0; i < count; i++ {
132+ for i := range count {
131133 require.Equal(t, []string{"repository", "catalogue"}, events[i*2:i*2+2])
132134 }
133135 return catalogue.record, catalogue, repository
134136 }
135137
136-func TestCheckpointCommitFirst(t *testing.T) {
137- record, catalogue, _ := runCheckpointCommit(t, 1)
138- require.Equal(t, uint64(1), record.Committed.Generation)
139- require.Empty(t, populatedGenerations(record.Fallbacks))
140- require.Equal(t, 1, catalogue.replaces)
141-}
142-
143-func TestCheckpointCommitSecond(t *testing.T) {
144- record, catalogue, _ := runCheckpointCommit(t, 2)
145- require.Equal(t, uint64(2), record.Committed.Generation)
146- require.Equal(t, []uint64{1}, populatedGenerations(record.Fallbacks))
147- require.Equal(t, 2, catalogue.replaces)
148-}
149-
150-func TestCheckpointCommitThird(t *testing.T) {
151- record, catalogue, _ := runCheckpointCommit(t, 4)
152- require.Equal(t, uint64(4), record.Committed.Generation)
153- require.Equal(t, []uint64{3, 2}, populatedGenerations(record.Fallbacks))
154- require.Equal(t, domain.RecoveryHealthy, record.RecoveryState)
155- require.Empty(t, record.DegradedReason)
156- require.Equal(t, 4, catalogue.replaces)
138+func TestCheckpointCommits(t *testing.T) {
139+ for _, tc := range []struct {
140+ name string
141+ count int
142+ wantCommitted uint64
143+ wantFallbacks []uint64
144+ }{
145+ {name: "first", count: 1, wantCommitted: 1},
146+ {name: "second", count: 2, wantCommitted: 2, wantFallbacks: []uint64{1}},
147+ {name: "third", count: 3, wantCommitted: 3, wantFallbacks: []uint64{2, 1}},
148+ {name: "fourth", count: 4, wantCommitted: 4, wantFallbacks: []uint64{3, 2}},
149+ } {
150+ t.Run(tc.name, func(t *testing.T) {
151+ record, catalogue, _ := runCheckpointCommit(t, tc.count)
152+ require.Equal(t, tc.wantCommitted, record.Committed.Generation)
153+ require.Equal(t, tc.wantFallbacks, populatedGenerations(record.Fallbacks))
154+ require.Equal(t, domain.RecoveryHealthy, record.RecoveryState)
155+ require.Empty(t, record.DegradedReason)
156+ require.Equal(t, tc.count, catalogue.replaces)
157+ })
158+ }
157159 }
158160
159161 func TestCheckpointCommitThirdPromotesOnlyValidatedDirectFallbacks(t *testing.T) {
M internal/usecase/recovery/coordinator.go +32 -8
@@ -148,7 +148,9 @@ func (c *Coordinator) Create(ctx context.Context, record domain.CatalogueRecord)
148148 }
149149 unlock := c.locks.Lock([]string{record.Name})
150150 defer unlock()
151- if _, exists := c.catalogue.Record(record.Name); exists {
151+ if _, exists, err := c.catalogue.Record(record.Name); err != nil {
152+ return domain.CatalogueRecord{}, err
153+ } else if exists {
152154 return domain.CatalogueRecord{}, fmt.Errorf("recovery: session %q already exists", record.Name)
153155 }
154156 id, err := domain.NewIncarnationID(c.random)
@@ -180,11 +182,16 @@ func (c *Coordinator) Rename(ctx context.Context, oldName, newName string) (doma
180182 }
181183 unlock := c.locks.Lock([]string{oldName, newName})
182184 defer unlock()
183- record, ok := c.catalogue.Record(oldName)
185+ record, ok, err := c.catalogue.Record(oldName)
186+ if err != nil {
187+ return domain.CatalogueRecord{}, err
188+ }
184189 if !ok {
185190 return domain.CatalogueRecord{}, fmt.Errorf("recovery: session %q not found", oldName)
186191 }
187- if existing, exists := c.catalogue.Record(newName); exists && (newName != oldName || existing.IncarnationID != record.IncarnationID) {
192+ if existing, exists, err := c.catalogue.Record(newName); err != nil {
193+ return domain.CatalogueRecord{}, err
194+ } else if exists && (newName != oldName || existing.IncarnationID != record.IncarnationID) {
188195 return domain.CatalogueRecord{}, fmt.Errorf("recovery: session %q already exists", newName)
189196 }
190197 record.Name = newName
@@ -207,7 +214,10 @@ func (c *Coordinator) Delete(ctx context.Context, name string) error {
207214 }
208215 unlock := c.locks.Lock([]string{name})
209216 defer unlock()
210- record, ok := c.catalogue.Record(name)
217+ record, ok, err := c.catalogue.Record(name)
218+ if err != nil {
219+ return err
220+ }
211221 if !ok {
212222 return nil
213223 }
@@ -267,13 +277,21 @@ func (c *Coordinator) Recover(ctx context.Context) error {
267277 }
268278 }
269279 }
270- for _, candidate := range c.catalogue.Records() {
280+ records, err := c.catalogue.Records()
281+ if err != nil {
282+ return err
283+ }
284+ for _, candidate := range records {
271285 if candidate.RecoveryState != domain.RecoveryDeleting {
272286 continue
273287 }
274288 unlock := c.locks.Lock([]string{candidate.Name})
275289 var deleteErr error
276- current, ok := c.catalogue.Record(candidate.Name)
290+ current, ok, readErr := c.catalogue.Record(candidate.Name)
291+ if readErr != nil {
292+ unlock()
293+ return readErr
294+ }
277295 if ok && current.IncarnationID == candidate.IncarnationID && current.RecoveryState == domain.RecoveryDeleting {
278296 deleteErr = c.deleteLocked(ctx, current)
279297 }
@@ -323,7 +341,10 @@ func (c *Coordinator) fenceSession(name, kind, reason string, cause error) error
323341
324342 unlock := c.locks.Lock([]string{name})
325343 defer unlock()
326- record, ok := c.catalogue.Record(name)
344+ record, ok, err := c.catalogue.Record(name)
345+ if err != nil {
346+ return err
347+ }
327348 if !ok || record.RecoveryState != domain.RecoveryHealthy {
328349 // Fresh records cannot represent degraded state (it requires a committed
329350 // checkpoint), and deleting or already degraded records must keep the
@@ -354,7 +375,10 @@ func (c *Coordinator) recoverDeletionTombstone(ctx context.Context, tombstone do
354375 }
355376 unlock := c.locks.Lock([]string{tombstone.Name})
356377 defer unlock()
357- record, exists := c.catalogue.Record(tombstone.Name)
378+ record, exists, err := c.catalogue.Record(tombstone.Name)
379+ if err != nil {
380+ return err
381+ }
358382 includeLegacyName := true
359383 if exists {
360384 switch {
M internal/usecase/recovery/discard.go +25 -15
@@ -39,7 +39,10 @@ func (c *Coordinator) Retry(ctx context.Context, name string) error {
3939 }
4040 unlock := c.locks.Lock([]string{name})
4141 defer unlock()
42- record, ok := c.catalogue.Record(name)
42+ record, ok, err := c.catalogue.Record(name)
43+ if err != nil {
44+ return err
45+ }
4346 if !ok {
4447 return ErrRecoveryRecordNotFound
4548 }
@@ -66,7 +69,10 @@ func (c *Coordinator) RestoreFallback(ctx context.Context, name string, ref doma
6669 }
6770 unlock := c.locks.Lock([]string{name})
6871 defer unlock()
69- record, ok := c.catalogue.Record(name)
72+ record, ok, err := c.catalogue.Record(name)
73+ if err != nil {
74+ return err
75+ }
7076 if !ok {
7177 return ErrRecoveryRecordNotFound
7278 }
@@ -88,7 +94,10 @@ func (c *Coordinator) Export(ctx context.Context, name string, w io.Writer) erro
8894 }
8995 unlock := c.locks.Lock([]string{name})
9096 defer unlock()
91- record, ok := c.catalogue.Record(name)
97+ record, ok, err := c.catalogue.Record(name)
98+ if err != nil {
99+ return err
100+ }
92101 if !ok {
93102 return ErrRecoveryRecordNotFound
94103 }
@@ -167,7 +176,10 @@ func (c *Coordinator) Discard(ctx context.Context, name, reason string) (domain.
167176 }
168177 unlock := c.locks.Lock([]string{name})
169178 defer unlock()
170- record, ok := c.catalogue.Record(name)
179+ record, ok, err := c.catalogue.Record(name)
180+ if err != nil {
181+ return domain.CatalogueRecord{}, err
182+ }
171183 if !ok {
172184 return domain.CatalogueRecord{}, ErrRecoveryRecordNotFound
173185 }
@@ -207,9 +219,15 @@ func (c *Coordinator) recoverDiscardLocked(ctx context.Context, intent domain.Di
207219 if err := validateDiscardIntent(intent); err != nil {
208220 return err
209221 }
210- current, ok := c.catalogue.Record(intent.SessionName)
222+ current, ok, err := c.catalogue.Record(intent.SessionName)
223+ if err != nil {
224+ return err
225+ }
211226 if !ok {
212- return fmt.Errorf("%w: missing session %q", ErrDiscardConflict, intent.SessionName)
227+ // Catalogue deletion is terminal for this session identity. There is no
228+ // live record to replace or quarantine, so retaining the intent would
229+ // manufacture a permanent startup conflict.
230+ return c.journal.DeleteDiscard(ctx, intent.OldIncarnation)
213231 }
214232 next := freshReplacement(intent)
215233 if err := next.Validate(); err != nil {
@@ -217,7 +235,7 @@ func (c *Coordinator) recoverDiscardLocked(ctx context.Context, intent domain.Di
217235 }
218236 switch current.IncarnationID {
219237 case intent.OldIncarnation:
220- if !catalogueRecordsEqual(current, intent.OldRecord) {
238+ if !current.Equal(intent.OldRecord) {
221239 return fmt.Errorf("%w: old record changed", ErrDiscardConflict)
222240 }
223241 case intent.NewIncarnation:
@@ -275,11 +293,3 @@ func freshReplacement(intent domain.DiscardIntent) domain.CatalogueRecord {
275293 next.DegradedReason = ""
276294 return next
277295 }
278-
279-func catalogueRecordsEqual(a, b domain.CatalogueRecord) bool {
280- return a.Name == b.Name && a.IncarnationID == b.IncarnationID && a.Cwd == b.Cwd &&
281- a.CreatedAt == b.CreatedAt && a.UpdatedAt == b.UpdatedAt && a.LastUsedSeq == b.LastUsedSeq &&
282- slices.Equal(a.TabNames, b.TabNames) && a.RecoveryState == b.RecoveryState &&
283- equalCheckpointRefs(a.Committed, b.Committed) && equalCheckpointRefs(a.Fallbacks[0], b.Fallbacks[0]) &&
284- equalCheckpointRefs(a.Fallbacks[1], b.Fallbacks[1]) && a.DegradedReason == b.DegradedReason
285-}
M internal/usecase/recovery/reconcile.go +26 -5
@@ -29,23 +29,41 @@ func (r *Reconciler) Step(ctx context.Context, cursor ports.ReconcileCursor) (po
2929 if err := ctx.Err(); err != nil {
3030 return cursor, nil, err
3131 }
32- next, findings, err := r.repository.Reconcile(ctx, r.catalogue.Records(), cursor, r.budget)
32+ records, err := r.catalogue.Records()
33+ if err != nil {
34+ return cursor, nil, err
35+ }
36+ next, findings, err := r.repository.Reconcile(ctx, records, cursor, r.budget)
3337 if err != nil {
3438 return cursor, nil, err
3539 }
3640 decisions := make([]ports.ReconcileDecision, 0, len(findings))
41+ consumed := ports.MaintenanceBudget{}
3742 for _, finding := range findings {
38- if finding.Consumed.Entries > r.budget.Entries || finding.Consumed.Bytes > r.budget.Bytes {
43+ // Compare against the remainder before adding. This both enforces the
44+ // page-wide budget and avoids uint64 wraparound.
45+ if finding.Consumed.Entries > r.budget.Entries-consumed.Entries ||
46+ finding.Consumed.Bytes > r.budget.Bytes-consumed.Bytes {
3947 return cursor, nil, errors.New("recovery: repository exceeded reconciliation budget")
4048 }
41- record, ok := r.catalogue.Record(finding.Candidate.Name)
49+ consumed.Entries += finding.Consumed.Entries
50+ consumed.Bytes += finding.Consumed.Bytes
51+ record, ok, err := r.catalogue.Record(finding.Candidate.Name)
52+ if err != nil {
53+ return cursor, nil, err
54+ }
4255 if !ok {
4356 record = domain.CatalogueRecord{}
4457 }
4558 decision := classifyFinding(record, finding)
4659 if decision.Kind == ports.ReconcileAdopt {
4760 if _, err := r.coordinator.PublishReconciledCheckpoint(ctx, finding.Candidate.Name, finding.Candidate, finding.AncestorChain); err != nil {
48- return cursor, nil, err
61+ if !errors.Is(err, ErrCheckpointConflict) && !errors.Is(err, ErrCheckpointRecordNotFound) {
62+ return cursor, nil, err
63+ }
64+ decision.Kind = ports.ReconcileDefer
65+ decision.ReasonCode = "catalogue-conflict"
66+ decision.RetentionResolved = false
4967 }
5068 }
5169 decisions = append(decisions, decision)
@@ -132,7 +150,10 @@ func (c *Coordinator) PublishReconciledCheckpoint(ctx context.Context, name stri
132150 if err := ctx.Err(); err != nil {
133151 return domain.CatalogueRecord{}, err
134152 }
135- record, ok := c.catalogue.Record(name)
153+ record, ok, err := c.catalogue.Record(name)
154+ if err != nil {
155+ return domain.CatalogueRecord{}, err
156+ }
136157 if !ok {
137158 return domain.CatalogueRecord{}, ErrCheckpointRecordNotFound
138159 }
M internal/usecase/recovery/reconcile_test.go +77 -0
@@ -24,6 +24,33 @@ type reconcilePage struct {
2424 err error
2525 }
2626
27+type reconcileConflictCatalogue struct {
28+ *transactionCatalogue
29+ stale domain.CatalogueRecord
30+ reads int
31+}
32+
33+func (c *reconcileConflictCatalogue) Records() ([]domain.CatalogueRecord, error) {
34+ records, err := c.transactionCatalogue.Records()
35+ if err != nil {
36+ return nil, err
37+ }
38+ for i := range records {
39+ if records[i].Name == c.stale.Name {
40+ records[i] = c.stale
41+ }
42+ }
43+ return records, nil
44+}
45+
46+func (c *reconcileConflictCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
47+ if name == c.stale.Name && c.reads == 0 {
48+ c.reads++
49+ return c.stale, true, nil
50+ }
51+ return c.transactionCatalogue.Record(name)
52+}
53+
2754 func (r *reconcileRepository) Reconcile(_ context.Context, _ []domain.CatalogueRecord, cursor ports.ReconcileCursor, budget ports.MaintenanceBudget) (ports.ReconcileCursor, []ports.ReconcileFinding, error) {
2855 r.seenCursors = append(r.seenCursors, cursor)
2956 r.seenBudgets = append(r.seenBudgets, budget)
@@ -64,6 +91,56 @@ func TestReconciliationCursor(t *testing.T) {
6491 require.Equal(t, []ports.MaintenanceBudget{{Entries: 2, Bytes: 32}, {Entries: 2, Bytes: 32}}, repository.seenBudgets)
6592 }
6693
94+func TestReconciliationRejectsCumulativeAndOverflowingBudgets(t *testing.T) {
95+ record := healthyReconcileRecord()
96+ finding := ports.ReconcileFinding{Status: ports.ReconcileBudgetExhausted, Candidate: ports.ReconcileCandidate{Name: record.Name}}
97+ for _, tc := range []struct {
98+ name string
99+ budget ports.MaintenanceBudget
100+ consumed []ports.MaintenanceBudget
101+ }{
102+ {name: "cumulative entries", budget: ports.MaintenanceBudget{Entries: 3, Bytes: 10}, consumed: []ports.MaintenanceBudget{{Entries: 2, Bytes: 1}, {Entries: 2, Bytes: 1}}},
103+ {name: "cumulative bytes", budget: ports.MaintenanceBudget{Entries: 10, Bytes: 3}, consumed: []ports.MaintenanceBudget{{Entries: 1, Bytes: 2}, {Entries: 1, Bytes: 2}}},
104+ {name: "overflow safe", budget: ports.MaintenanceBudget{Entries: ^uint64(0), Bytes: ^uint64(0)}, consumed: []ports.MaintenanceBudget{{Entries: ^uint64(0), Bytes: ^uint64(0)}, {Entries: 1, Bytes: 1}}},
105+ } {
106+ t.Run(tc.name, func(t *testing.T) {
107+ findings := make([]ports.ReconcileFinding, len(tc.consumed))
108+ for i, usage := range tc.consumed {
109+ findings[i] = finding
110+ findings[i].Consumed = usage
111+ }
112+ repository := &reconcileRepository{pages: map[uint64]reconcilePage{0: {findings: findings}}}
113+ catalogue := &checkpointCatalogue{record: record}
114+ reconciler := NewReconciler(NewCoordinator(catalogue, repository, nil, nil), catalogue, repository, tc.budget)
115+ _, _, err := reconciler.Step(context.Background(), ports.ReconcileCursor{})
116+ require.EqualError(t, err, "recovery: repository exceeded reconciliation budget")
117+ })
118+ }
119+}
120+
121+func TestReconciliationDefersConflictAndContinues(t *testing.T) {
122+ first := healthyReconcileRecord()
123+ first.Name = "first"
124+ staleRecord := first
125+ stale := forwardCandidate(staleRecord, 2, staleRecord.Committed)
126+ first = shiftedCheckpoint(first, domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{9}})
127+ second := healthyReconcileRecord()
128+ second.Name = "second"
129+ adoptable := forwardCandidate(second, 2, second.Committed)
130+ catalogue := &reconcileConflictCatalogue{transactionCatalogue: newTransactionCatalogue(first, second), stale: staleRecord}
131+ repository := &reconcileRepository{pages: map[uint64]reconcilePage{0: {findings: []ports.ReconcileFinding{
132+ {Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: stale, Consumed: ports.MaintenanceBudget{Entries: 1, Bytes: 1}},
133+ {Kind: ports.ReconcileForwardOrphan, Status: ports.ReconcileValidated, Candidate: adoptable, Consumed: ports.MaintenanceBudget{Entries: 1, Bytes: 1}},
134+ }}}}
135+ reconciler := NewReconciler(NewCoordinator(catalogue, repository, nil, nil), catalogue, repository, ports.MaintenanceBudget{Entries: 2, Bytes: 2})
136+
137+ _, decisions, err := reconciler.Step(context.Background(), ports.ReconcileCursor{})
138+ require.NoError(t, err)
139+ require.Equal(t, []ports.ReconcileDecisionKind{ports.ReconcileDefer, ports.ReconcileAdopt}, []ports.ReconcileDecisionKind{decisions[0].Kind, decisions[1].Kind})
140+ require.Equal(t, "catalogue-conflict", decisions[0].ReasonCode)
141+ require.Equal(t, adoptable.Ref, *catalogue.records[second.Name].Committed)
142+}
143+
67144 func TestForwardOrphanAdoption(t *testing.T) {
68145 for _, tc := range []struct {
69146 name string
M internal/usecase/recovery/transactions_test.go +21 -10
@@ -27,20 +27,20 @@ func newTransactionCatalogue(records ...domain.CatalogueRecord) *transactionCata
2727 return catalogue
2828 }
2929
30-func (c *transactionCatalogue) Records() []domain.CatalogueRecord {
30+func (c *transactionCatalogue) Records() ([]domain.CatalogueRecord, error) {
3131 c.mu.Lock()
3232 defer c.mu.Unlock()
3333 out := make([]domain.CatalogueRecord, 0, len(c.records))
3434 for _, record := range c.records {
3535 out = append(out, record)
3636 }
37- return out
37+ return out, nil
3838 }
39-func (c *transactionCatalogue) Record(name string) (domain.CatalogueRecord, bool) {
39+func (c *transactionCatalogue) Record(name string) (domain.CatalogueRecord, bool, error) {
4040 c.mu.Lock()
4141 defer c.mu.Unlock()
4242 record, ok := c.records[name]
43- return record, ok
43+ return record, ok, nil
4444 }
4545 func (c *transactionCatalogue) Create(record domain.CatalogueRecord) error {
4646 c.mu.Lock()
@@ -233,16 +233,13 @@ func TestFallbackPromotion(t *testing.T) {
233233 fallback := *record.Fallbacks[0]
234234 catalogue := newTransactionCatalogue(record)
235235 repository := newTransactionRepository()
236- repository.generations[fallback] = ports.SnapshotGeneration{
237- IncarnationID: record.IncarnationID, Name: record.Name, Generation: fallback.Generation,
238- Manifest: []byte("fallback"),
239- }
240- fallback.ManifestDigest = checkpointDigest(repository.generations[*record.Fallbacks[0]].Manifest)
236+ manifest := []byte("fallback")
237+ fallback.ManifestDigest = checkpointDigest(manifest)
241238 record.Fallbacks[0] = &fallback
242239 catalogue.records[record.Name] = record
243240 repository.generations[fallback] = ports.SnapshotGeneration{
244241 IncarnationID: record.IncarnationID, Name: record.Name, Generation: fallback.Generation,
245- Manifest: []byte("fallback"),
242+ Manifest: manifest,
246243 }
247244 coordinator := NewCoordinator(catalogue, repository, newTransactionJournal(), nil)
248245
@@ -283,6 +280,20 @@ func TestDegradedExportIsReadOnly(t *testing.T) {
283280
284281 // A third incarnation still fails closed for that session: its intent and
285282 // record are retained untouched, but startup is no longer aborted.
283+func TestDiscardRecoveryRemovesIntentWhenCatalogueRecordIsAbsent(t *testing.T) {
284+ old := degradedTransactionRecord()
285+ intent := domain.DiscardIntent{OldRecord: old, OldIncarnation: old.IncarnationID, NewIncarnation: domain.IncarnationID{2}, SessionName: old.Name, Reason: "discard"}
286+ catalogue := newTransactionCatalogue()
287+ journal := newTransactionJournal()
288+ journal.intents[old.IncarnationID] = intent
289+ coordinator := NewCoordinator(catalogue, newTransactionRepository(), journal, nil)
290+
291+ require.NoError(t, coordinator.Recover(context.Background()))
292+ require.NotContains(t, journal.intents, old.IncarnationID)
293+ require.Equal(t, []string{"delete-intent"}, journal.events)
294+ require.Empty(t, coordinator.Conflicts())
295+}
296+
286297 func TestDiscardIncarnationConflict(t *testing.T) {
287298 old := degradedTransactionRecord()
288299 intent := domain.DiscardIntent{OldRecord: old, OldIncarnation: old.IncarnationID, NewIncarnation: domain.IncarnationID{2}, SessionName: old.Name, Reason: "discard"}
M internal/usecase/snapshot/manifest_preflight.go +4 -1
@@ -16,7 +16,10 @@ func preflightManifest(body []byte) error {
1616 if err != nil {
1717 return err
1818 }
19- if generation == 0 || len(r.b) < len(domain.IncarnationID{}) {
19+ if generation == 0 {
20+ return fmt.Errorf("%w: manifest generation", ErrInvalidData)
21+ }
22+ if len(r.b) < len(domain.IncarnationID{}) {
2023 return ErrShortPayload
2124 }
2225 var incarnationID domain.IncarnationID
M internal/usecase/snapshot/manifest_preflight_test.go +55 -3
@@ -40,6 +40,7 @@ func TestManifestEnvelopePreflightParity(t *testing.T) {
4040 if err != nil {
4141 t.Fatal(err)
4242 }
43+ nameOffset, nodeKindOffset := manifestFieldOffsets(t, encoded)
4344 mutations := []struct {
4445 name string
4546 edit func([]byte) []byte
@@ -51,9 +52,21 @@ func TestManifestEnvelopePreflightParity(t *testing.T) {
5152 {"body length", func(b []byte) []byte { binary.BigEndian.PutUint32(b[8:12], uint32(len(b))); return b }},
5253 {"crc", func(b []byte) []byte { b[12] ^= 1; return b }},
5354 {"trailing", func(b []byte) []byte { return append(b, 0) }},
54- {"body name", func(b []byte) []byte { b[26] ^= 1; binary.BigEndian.PutUint32(b[12:16], checksum(b[16:])); return b }},
55- {"body generation", func(b []byte) []byte { b[16] ^= 1; binary.BigEndian.PutUint32(b[12:16], checksum(b[16:])); return b }},
56- {"body node kind", func(b []byte) []byte { b[65] = 99; binary.BigEndian.PutUint32(b[12:16], checksum(b[16:])); return b }},
55+ {"body name", func(b []byte) []byte {
56+ b[nameOffset] = 0
57+ binary.BigEndian.PutUint32(b[12:16], checksum(b[16:]))
58+ return b
59+ }},
60+ {"body generation", func(b []byte) []byte {
61+ clear(b[manifestHeaderSize : manifestHeaderSize+8])
62+ binary.BigEndian.PutUint32(b[12:16], checksum(b[16:]))
63+ return b
64+ }},
65+ {"body node kind", func(b []byte) []byte {
66+ b[nodeKindOffset] = 99
67+ binary.BigEndian.PutUint32(b[12:16], checksum(b[16:]))
68+ return b
69+ }},
5770 }
5871 for _, tc := range mutations {
5972 t.Run(tc.name, func(t *testing.T) {
@@ -68,6 +81,45 @@ func TestManifestEnvelopePreflightParity(t *testing.T) {
6881 }
6982 }
7083
84+func manifestFieldOffsets(t *testing.T, encoded []byte) (nameOffset, nodeKindOffset int) {
85+ t.Helper()
86+ r := payloadReader{b: encoded[manifestHeaderSize:]}
87+ must := func(err error) {
88+ if err != nil {
89+ t.Fatal(err)
90+ }
91+ }
92+ _, err := r.getUint64()
93+ must(err)
94+ r.b = r.b[len(domain.IncarnationID{}):]
95+ _, err = r.getUint8()
96+ must(err)
97+ nameLengthOffset := len(encoded) - len(r.b)
98+ name, err := r.getString()
99+ must(err)
100+ nameOffset = nameLengthOffset + 2
101+ if len(name) == 0 {
102+ t.Fatal("test manifest has empty name")
103+ }
104+ _, err = r.getUint64()
105+ must(err)
106+ _, err = r.getUint16()
107+ must(err)
108+ _, err = r.getUint16()
109+ must(err)
110+ _, err = r.getString()
111+ must(err)
112+ _, err = r.getUint16()
113+ must(err)
114+ _, err = r.getUint16()
115+ must(err)
116+ _, err = r.getUint64()
117+ must(err)
118+ _, err = r.getString()
119+ must(err)
120+ return nameOffset, len(encoded) - len(r.b)
121+}
122+
71123 func TestManifestObjectRefKinds(t *testing.T) {
72124 for _, tc := range []struct {
73125 name string
M pkg/kv/codec.go +12 -7
@@ -19,8 +19,9 @@ const (
1919 // place by Open; nothing ever writes this framing again.
2020 legacyHeaderLen = 8
2121
22- // Current framing. Every file starts with fileMagic plus a uint16 format
23- // version, and every record carries a self-verifying header:
22+ // Current framing. Every file starts with fileMagic, a uint16 format
23+ // version, and CRC32 over that six-byte identity. Every record carries a
24+ // self-verifying header:
2425 //
2526 // [0:4] payload length, big endian
2627 // [4:8] CRC32(payload)
@@ -29,10 +30,13 @@ const (
2930 // The header checksum is what makes the length trustworthy: a corrupt
3031 // length now fails closed instead of masquerading as a torn tail and
3132 // silently truncating every record that follows it.
32- recordHeaderLen = 12
33- fileMagicLen = 4
34- fileHeaderLen = fileMagicLen + 2
35- formatVersion = 1
33+ recordHeaderLen = 12
34+ fileMagicLen = 4
35+ fileHeaderV1Len = fileMagicLen + 2
36+ fileHeaderBodyLen = fileHeaderV1Len
37+ fileHeaderLen = fileHeaderBodyLen + 4
38+ formatVersionV1 = 1
39+ formatVersion = 2
3640
3741 // maxPayloadLen bounds any allocation driven by a length field. Catalogue
3842 // records and batches are orders of magnitude smaller than this.
@@ -145,7 +149,8 @@ func encodeBatch(changes []BatchChange) ([]byte, error) {
145149 func fileHeader() []byte {
146150 buf := make([]byte, fileHeaderLen)
147151 copy(buf, fileMagic[:])
148- binary.BigEndian.PutUint16(buf[fileMagicLen:], formatVersion)
152+ binary.BigEndian.PutUint16(buf[fileMagicLen:fileHeaderBodyLen], formatVersion)
153+ binary.BigEndian.PutUint32(buf[fileHeaderBodyLen:], crc32.ChecksumIEEE(buf[:fileHeaderBodyLen]))
149154 return buf
150155 }
151156
M pkg/kv/kv.go +100 -48
@@ -36,16 +36,30 @@ const (
3636 // formatLegacy is a file written before the record header carried its own
3737 // checksum. It is read with the original semantics and upgraded at Open.
3838 formatLegacy walFormat = iota
39+ // formatCurrentV1 is the released VEVK v1 framing with its six-byte file
40+ // header. Its record headers are identical to the current format.
41+ formatCurrentV1
3942 formatCurrent
4043 )
4144
4245 func (f walFormat) headerLen() int64 {
43- if f == formatCurrent {
46+ if f != formatLegacy {
4447 return recordHeaderLen
4548 }
4649 return legacyHeaderLen
4750 }
4851
52+func (f walFormat) fileHeaderLen() int64 {
53+ switch f {
54+ case formatCurrentV1:
55+ return fileHeaderV1Len
56+ case formatCurrent:
57+ return fileHeaderLen
58+ default:
59+ return 0
60+ }
61+}
62+
4963 type headerStatus uint8
5064
5165 const (
@@ -466,10 +480,7 @@ func replayFile(f *os.File) (ReplayResult, error) {
466480 if err != nil {
467481 return ReplayResult{}, err
468482 }
469- result := ReplayResult{Data: make(map[string][]byte), EntrySize: make(map[string]int64), Format: format}
470- if format == formatCurrent {
471- result.LastGood = fileHeaderLen
472- }
483+ result := ReplayResult{Data: make(map[string][]byte), EntrySize: make(map[string]int64), Format: format, LastGood: format.fileHeaderLen()}
473484 for {
474485 header, status, err := readRecordHeader(f, format, fileSize, result.LastGood)
475486 if err != nil {
@@ -501,7 +512,7 @@ func replayFile(f *os.File) (ReplayResult, error) {
501512 records = []record{rec}
502513 }
503514 if err != nil {
504- return ReplayResult{}, fmt.Errorf("%w: invalid record at offset %d: %v", ErrCorruptWAL, result.LastGood, err)
515+ return ReplayResult{}, fmt.Errorf("%w: invalid record at offset %d: %w", ErrCorruptWAL, result.LastGood, err)
505516 }
506517 size := format.headerLen() + int64(len(payload))
507518 applyRecords(result.Data, result.EntrySize, &result.Live, records, size)
@@ -513,23 +524,60 @@ func replayFile(f *os.File) (ReplayResult, error) {
513524 // readFileFormat consumes the file header when one is present and leaves the
514525 // reader positioned at the first record either way.
515526 func readFileFormat(f io.ReadSeeker, fileSize int64) (walFormat, error) {
516- if fileSize < fileHeaderLen {
527+ if fileSize == 0 {
517528 return formatLegacy, nil
518529 }
519- header := make([]byte, fileHeaderLen)
520- if _, err := io.ReadFull(f, header); err != nil {
530+ probeLen := min(fileSize, int64(fileHeaderLen))
531+ probe := make([]byte, probeLen)
532+ if _, err := io.ReadFull(f, probe); err != nil {
521533 return formatLegacy, err
522534 }
523- if !bytes.Equal(header[:fileMagicLen], fileMagic[:]) {
535+ if fileSize < fileMagicLen {
536+ if bytes.Equal(probe, fileMagic[:len(probe)]) {
537+ return formatLegacy, fmt.Errorf("%w: incomplete VEVK header", ErrCorruptWAL)
538+ }
524539 if _, err := f.Seek(0, io.SeekStart); err != nil {
525540 return formatLegacy, err
526541 }
527542 return formatLegacy, nil
528543 }
529- if version := binary.BigEndian.Uint16(header[fileMagicLen:]); version != formatVersion {
544+ if !bytes.Equal(probe[:fileMagicLen], fileMagic[:]) {
545+ // A complete v2 identity checksum authenticates the intended magic as
546+ // well as the version. Detect it before legacy replay so damage to the
547+ // first magic byte cannot masquerade as a small legacy length and be
548+ // truncated or compacted away.
549+ if len(probe) == fileHeaderLen &&
550+ binary.BigEndian.Uint16(probe[fileMagicLen:fileHeaderBodyLen]) == formatVersion &&
551+ binary.BigEndian.Uint32(probe[fileHeaderBodyLen:]) == binary.BigEndian.Uint32(fileHeader()[fileHeaderBodyLen:]) {
552+ return formatLegacy, fmt.Errorf("%w: damaged VEVK v2 magic", ErrCorruptWAL)
553+ }
554+ if _, err := f.Seek(0, io.SeekStart); err != nil {
555+ return formatLegacy, err
556+ }
557+ return formatLegacy, nil
558+ }
559+ if fileSize < fileHeaderV1Len {
560+ return formatLegacy, fmt.Errorf("%w: incomplete VEVK header", ErrCorruptWAL)
561+ }
562+ prefix := probe[:fileHeaderV1Len]
563+ if _, err := f.Seek(fileHeaderV1Len, io.SeekStart); err != nil {
564+ return formatLegacy, err
565+ }
566+ switch version := binary.BigEndian.Uint16(prefix[fileMagicLen:]); version {
567+ case formatVersionV1:
568+ return formatCurrentV1, nil
569+ case formatVersion:
570+ checksum := make([]byte, fileHeaderLen-fileHeaderV1Len)
571+ if _, err := io.ReadFull(f, checksum); err != nil {
572+ return formatLegacy, fmt.Errorf("%w: incomplete VEVK v2 header", ErrCorruptWAL)
573+ }
574+ if crc32.ChecksumIEEE(prefix) != binary.BigEndian.Uint32(checksum) {
575+ return formatLegacy, fmt.Errorf("%w: file header checksum mismatch", ErrCorruptWAL)
576+ }
577+ return formatCurrent, nil
578+ default:
530579 return formatLegacy, fmt.Errorf("%w: unsupported format version %d", ErrCorruptWAL, version)
531580 }
532- return formatCurrent, nil
533581 }
534582
535583 type recordHeader struct {
@@ -554,15 +602,18 @@ func readRecordHeader(f io.Reader, format walFormat, fileSize, offset int64) (re
554602 payloadLen: binary.BigEndian.Uint32(buf[0:4]),
555603 payloadCRC: binary.BigEndian.Uint32(buf[4:8]),
556604 }
557- if format == formatCurrent {
605+ if format != formatLegacy {
558606 if crc32.ChecksumIEEE(buf[0:8]) != binary.BigEndian.Uint32(buf[8:recordHeaderLen]) {
559607 return recordHeader{}, headerOK, fmt.Errorf("%w: record header CRC mismatch at offset %d", ErrCorruptWAL, offset)
560608 }
561609 // The length is now trustworthy, so an implausible one is corruption
562610 // rather than a short tail. Checked before any allocation.
563- if header.payloadLen > maxPayloadLen {
564- return recordHeader{}, headerOK, fmt.Errorf("%w: record length %d exceeds maximum at offset %d", ErrCorruptWAL, header.payloadLen, offset)
565- }
611+ }
612+ // Every framing is allocation-bounded. In legacy framing an oversized
613+ // declaration remains corruption rather than driving an attacker-sized
614+ // allocation; ordinary short tails retain their released truncation semantics.
615+ if header.payloadLen > maxPayloadLen {
616+ return recordHeader{}, headerOK, fmt.Errorf("%w: record length %d exceeds maximum at offset %d", ErrCorruptWAL, header.payloadLen, offset)
566617 }
567618 if int64(header.payloadLen) > fileSize-offset-format.headerLen() {
568619 return recordHeader{}, headerTorn, nil
@@ -577,9 +628,9 @@ func applyRecords(data map[string][]byte, sizes map[string]int64, live *int64, r
577628 setCount++
578629 }
579630 }
580- setSize := size
631+ setSize, remainder := size, int64(0)
581632 if setCount > 1 {
582- setSize = size / setCount
633+ setSize, remainder = size/setCount, size%setCount
583634 }
584635 for _, rec := range records {
585636 key := string(rec.key)
@@ -587,9 +638,14 @@ func applyRecords(data map[string][]byte, sizes map[string]int64, live *int64, r
587638 *live -= old
588639 }
589640 if rec.op == opSet {
641+ entrySize := setSize
642+ if remainder > 0 {
643+ entrySize++
644+ remainder--
645+ }
590646 data[key] = append([]byte(nil), rec.value...)
591- sizes[key] = setSize
592- *live += setSize
647+ sizes[key] = entrySize
648+ *live += entrySize
593649 } else {
594650 delete(data, key)
595651 delete(sizes, key)
@@ -598,7 +654,7 @@ func applyRecords(data map[string][]byte, sizes map[string]int64, live *int64, r
598654 }
599655
600656 func recoverCompaction(path string, hooks fsHooks) error {
601- _, currentExists, currentErr := candidate(path)
657+ currentResult, currentExists, currentErr := candidate(path)
602658 nextResult, nextExists, nextErr := candidate(path + ".next")
603659 _, prevExists, prevErr := candidate(path + ".prev")
604660 if nextErr == nil && nextResult.TornTail {
@@ -606,6 +662,12 @@ func recoverCompaction(path string, hooks fsHooks) error {
606662 }
607663 dir := filepath.Dir(path)
608664 if currentExists && currentErr == nil {
665+ if currentResult.TornTail && prevExists {
666+ // A predecessor proves compaction was in flight. The torn current is
667+ // ambiguous, so preserve both candidates and fail closed rather than
668+ // truncating current and deleting the last complete predecessor.
669+ return fmt.Errorf("%w: torn current with predecessor", ErrCorruptWAL)
670+ }
609671 if nextExists {
610672 if err := hooks.Remove(path + ".next"); err != nil {
611673 return err
@@ -625,33 +687,23 @@ func recoverCompaction(path string, hooks fsHooks) error {
625687 return nil
626688 }
627689 if currentExists && currentErr != nil {
628- if !prevExists || prevErr != nil {
629- return fmt.Errorf("%w: no valid current or predecessor", ErrCorruptWAL)
630- }
631- if err := hooks.Remove(path); err != nil {
632- return err
633- }
634- if err := hooks.Rename(path+".prev", path); err != nil {
635- return err
636- }
637- if err := hooks.SyncDir(dir); err != nil {
638- return err
639- }
640- if nextExists {
641- if err := hooks.Remove(path + ".next"); err != nil {
642- return err
643- }
644- return hooks.SyncDir(dir)
645- }
646- return nil
647- }
648- if !currentExists && prevExists {
649- if prevErr != nil {
650- return fmt.Errorf("%w: invalid predecessor", ErrCorruptWAL)
651- }
652- source := path + ".prev"
690+ // Corruption does not prove which bytes are disposable. Preserve every
691+ // candidate for diagnosis/recovery instead of deleting current or a valid
692+ // predecessor and fail closed.
693+ return fmt.Errorf("%w: invalid current candidate", ErrCorruptWAL)
694+ }
695+ if !currentExists && (nextExists || prevExists) {
696+ // A complete .next is the newest fully published compaction candidate and
697+ // wins even when .prev exists but is corrupt. Otherwise a complete .prev
698+ // is the only safe rollback. Invalid candidates are never selected.
699+ source := ""
653700 if nextExists && nextErr == nil {
654701 source = path + ".next"
702+ } else if prevExists && prevErr == nil {
703+ source = path + ".prev"
704+ }
705+ if source == "" {
706+ return fmt.Errorf("%w: no valid current, successor, or predecessor", ErrCorruptWAL)
655707 }
656708 if err := hooks.Rename(source, path); err != nil {
657709 return err
@@ -668,8 +720,8 @@ func recoverCompaction(path string, hooks fsHooks) error {
668720 }
669721 return hooks.SyncDir(dir)
670722 }
671- if nextExists || (currentExists && currentErr != nil) || (prevExists && prevErr != nil) {
672- return fmt.Errorf("%w: no valid current or predecessor", ErrCorruptWAL)
723+ if currentExists && currentErr != nil {
724+ return fmt.Errorf("%w: no valid current", ErrCorruptWAL)
673725 }
674726 return nil
675727 }
M pkg/kv/kv_test.go +168 -19
@@ -4,7 +4,9 @@ import (
44 "bytes"
55 "encoding/binary"
66 "errors"
7+ "fmt"
78 "hash/crc32"
9+ "maps"
810 "os"
911 "path/filepath"
1012 "reflect"
@@ -26,6 +28,17 @@ func currentFile(records ...[]byte) []byte {
2628 return buf
2729 }
2830
31+// v1File is the previously released VEVK framing: magic and uint16 version,
32+// followed directly by the same self-checksummed records used today.
33+func v1File(records ...[]byte) []byte {
34+ buf := append([]byte(nil), fileMagic[:]...)
35+ buf = binary.BigEndian.AppendUint16(buf, formatVersionV1)
36+ for _, record := range records {
37+ buf = append(buf, record...)
38+ }
39+ return buf
40+}
41+
2942 // legacyFile reproduces the framing written by the released version: no file
3043 // header, and a record header of length plus payload CRC only. Compatibility
3144 // with these files is a hard requirement, so the fixture stays in the tests.
@@ -378,6 +391,34 @@ func TestRecoverCompactionCleansStaleNextRegardlessOfPrev(t *testing.T) {
378391 }
379392 }
380393
394+func TestReplayPreservesWrappedDecodeCause(t *testing.T) {
395+ path := privatePath(t)
396+ writeFile(t, path, currentFile(rawRecord([]byte{0xff, 0, 0})))
397+ f, err := os.Open(path)
398+ if err != nil {
399+ t.Fatal(err)
400+ }
401+ defer func() { _ = f.Close() }()
402+ _, err = replayFile(f)
403+ if !errors.Is(err, ErrCorruptWAL) || !errors.Is(err, errBadRecord) {
404+ t.Fatalf("replayFile error = %v, want wrapped ErrCorruptWAL and errBadRecord", err)
405+ }
406+}
407+
408+func TestBatchLiveSizeAccountsForRemainderExactly(t *testing.T) {
409+ data := map[string][]byte{}
410+ sizes := map[string]int64{}
411+ var live int64
412+ applyRecords(data, sizes, &live, []record{
413+ {op: opSet, key: []byte("a"), value: []byte("one")},
414+ {op: opSet, key: []byte("b"), value: []byte("two")},
415+ {op: opSet, key: []byte("c"), value: []byte("three")},
416+ }, 10)
417+ if live != 10 || sizes["a"]+sizes["b"]+sizes["c"] != 10 {
418+ t.Fatalf("live sizes = %v, live = %d, want exact total 10", sizes, live)
419+ }
420+}
421+
381422 func TestDecodeBatchRejectsImpossibleCountBeforeAllocating(t *testing.T) {
382423 payload := []byte{opBatch, 0xff, 0xff, 0xff, 0xff}
383424 allocs := testing.AllocsPerRun(10, func() {
@@ -429,14 +470,17 @@ func TestCompactionRecoveryMatrix(t *testing.T) {
429470 name string
430471 current, next, prev []byte
431472 want string
473+ wantErr bool
432474 }{
433475 {name: "current-alone", current: oldFile, want: "old"},
434476 {name: "current-next", current: oldFile, next: newFile, want: "old"},
435477 {name: "prev-next", prev: oldFile, next: newFile, want: "new"},
436- {name: "prev-invalid-next", prev: oldFile, next: currentFile(rawRecord([]byte{0xff, 0, 0})), want: "old"},
478+ {name: "next-alone", next: newFile, want: "new"},
479+ {name: "invalid-prev-valid-next", prev: currentFile(rawRecord([]byte{0xff, 0, 0})), next: newFile, want: "new"},
480+ {name: "valid-prev-invalid-next", prev: oldFile, next: currentFile(rawRecord([]byte{0xff, 0, 0})), want: "old"},
437481 {name: "prev-torn-next", prev: oldFile, next: newFile[:len(newFile)-1], want: "old"},
438482 {name: "current-prev", current: newFile, prev: oldFile, want: "new"},
439- {name: "invalid-current-prev", current: currentFile(rawRecord([]byte{0xff, 0, 0})), prev: oldFile, want: "old"},
483+ {name: "invalid-current-prev", current: currentFile(rawRecord([]byte{0xff, 0, 0})), prev: oldFile, wantErr: true},
440484 {name: "prev-alone", prev: oldFile, want: "old"},
441485 }
442486 for _, tt := range tests {
@@ -452,7 +496,18 @@ func TestCompactionRecoveryMatrix(t *testing.T) {
452496 }
453497 }
454498 }
455- if err := recoverCompaction(path, defaultFSHooks()); err != nil {
499+ before := candidateBytes(t, path)
500+ err := recoverCompaction(path, defaultFSHooks())
501+ if tt.wantErr {
502+ if !errors.Is(err, ErrCorruptWAL) {
503+ t.Fatalf("recoverCompaction error = %v, want ErrCorruptWAL", err)
504+ }
505+ if got := candidateBytes(t, path); !reflect.DeepEqual(got, before) {
506+ t.Fatalf("uncertain candidates changed: got %#v, want %#v", got, before)
507+ }
508+ return
509+ }
510+ if err != nil {
456511 t.Fatal(err)
457512 }
458513 got, err := Replay(path)
@@ -701,25 +756,85 @@ func rawBatchPayload(changes []BatchChange) []byte {
701756 return payload
702757 }
703758
704-// An oversized length must never drive an allocation. In a legacy file the
705-// length is unprotected, so it stays a torn tail; in the current format the
706-// header checksum proves the length was written that way, which is corruption.
759+// An oversized length must never drive an allocation. Legacy framing is
760+// accepted only after complete strict replay, while the current header checksum
761+// proves an oversized length was written that way; both fail closed.
762+func TestCorruptedCurrentHeaderNeverFallsBackToLegacyOrDeletesPredecessor(t *testing.T) {
763+ record, _ := encodeRecord(opSet, []byte("version"), []byte("current"))
764+ previous, _ := encodeRecord(opSet, []byte("version"), []byte("previous"))
765+
766+ tests := []struct {
767+ name string
768+ current []byte
769+ standalone bool
770+ }{
771+ {name: "torn file header", current: append([]byte(nil), fileHeader()[:fileHeaderLen-1]...), standalone: true},
772+ {name: "torn current record", current: currentFile(record)[:fileHeaderLen+recordHeaderLen+1]},
773+ }
774+ for i := range fileMagicLen {
775+ corrupted := currentFile(record)
776+ corrupted[i] = 0
777+ tests = append(tests, struct {
778+ name string
779+ current []byte
780+ standalone bool
781+ }{name: fmt.Sprintf("corrupted magic byte %d", i), current: corrupted, standalone: true})
782+ }
783+ for length := 1; length < fileHeaderLen; length++ {
784+ tests = append(tests, struct {
785+ name string
786+ current []byte
787+ standalone bool
788+ }{name: fmt.Sprintf("torn header prefix %d", length), current: append([]byte(nil), fileHeader()[:length]...), standalone: true})
789+ }
790+
791+ for _, tt := range tests {
792+ t.Run(tt.name, func(t *testing.T) {
793+ for _, withCandidates := range []bool{false, true} {
794+ if !withCandidates && !tt.standalone {
795+ continue
796+ }
797+ name := "standalone"
798+ if withCandidates {
799+ name = "with candidates"
800+ }
801+ t.Run(name, func(t *testing.T) {
802+ path := privatePath(t)
803+ writeFile(t, path, tt.current)
804+ if withCandidates {
805+ if err := os.WriteFile(path+".prev", currentFile(previous), 0o600); err != nil {
806+ t.Fatal(err)
807+ }
808+ if err := os.WriteFile(path+".next", currentFile(record), 0o600); err != nil {
809+ t.Fatal(err)
810+ }
811+ }
812+ before := candidateBytes(t, path)
813+ if _, err := Open(path); !errors.Is(err, ErrCorruptWAL) {
814+ t.Fatalf("Open error = %v, want ErrCorruptWAL", err)
815+ }
816+ if got := candidateBytes(t, path); !reflect.DeepEqual(got, before) {
817+ t.Fatalf("candidates changed: got %#v, want %#v", got, before)
818+ }
819+ })
820+ }
821+ })
822+ }
823+}
824+
707825 func TestReplayRejectsOversizedLengthWithoutAllocating(t *testing.T) {
708826 first, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
709827 legacyHeader := make([]byte, legacyHeaderLen)
710828 binary.BigEndian.PutUint32(legacyHeader[0:4], 1<<31)
711829
712- t.Run("legacy length is a torn tail", func(t *testing.T) {
830+ t.Run("legacy incomplete record fails closed", func(t *testing.T) {
713831 path := privatePath(t)
714- writeFile(t, path, append(legacyFile(first), legacyHeader...))
715- s, err := Open(path)
716- if err != nil {
717- t.Fatal(err)
718- }
719- defer closeStore(t, s)
720- if got, ok := s.Get([]byte("a")); !ok || string(got) != "one" {
721- t.Fatalf("good record missing before bad length: %q %v", got, ok)
832+ wal := append(legacyFile(first), legacyHeader...)
833+ writeFile(t, path, wal)
834+ if _, err := Open(path); !errors.Is(err, ErrCorruptWAL) {
835+ t.Fatalf("Open error = %v, want ErrCorruptWAL", err)
722836 }
837+ requireFileBytes(t, path, wal)
723838 })
724839
725840 t.Run("current oversized length fails closed", func(t *testing.T) {
@@ -851,13 +966,49 @@ func TestCurrentFormatReplayDecisionTable(t *testing.T) {
851966
852967 // A file written by the released version must keep working: it opens with the
853968 // original semantics, is upgraded in place, and reopens identically.
969+func TestVEVKV1FileOpensAndUpgradesInPlace(t *testing.T) {
970+ setA, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
971+ setB, _ := encodeRecord(opSet, []byte("b"), bytes.Repeat([]byte("x"), 1<<20))
972+ for _, tc := range []struct {
973+ name string
974+ wal []byte
975+ }{
976+ {name: "ordinary catalogue payload", wal: v1File(setA)},
977+ {name: "prior large catalogue payload", wal: v1File(setB)},
978+ } {
979+ t.Run(tc.name, func(t *testing.T) {
980+ path := privatePath(t)
981+ writeFile(t, path, tc.wal)
982+ store, err := Open(path)
983+ if err != nil {
984+ t.Fatal(err)
985+ }
986+ closeStore(t, store)
987+ upgraded, err := os.ReadFile(path)
988+ if err != nil {
989+ t.Fatal(err)
990+ }
991+ if !bytes.Equal(upgraded[:fileHeaderLen], fileHeader()) {
992+ t.Fatalf("upgraded header = %x, want %x", upgraded[:fileHeaderLen], fileHeader())
993+ }
994+ })
995+ }
996+}
997+
998+func TestVEVKV1RejectsOversizedRecordBeforeAllocation(t *testing.T) {
999+ path := privatePath(t)
1000+ writeFile(t, path, v1File(oversizedRecordHeader(maxPayloadLen+1)))
1001+ if _, err := Open(path); !errors.Is(err, ErrCorruptWAL) {
1002+ t.Fatalf("Open error = %v, want ErrCorruptWAL", err)
1003+ }
1004+}
1005+
8541006 func TestLegacyFileOpensAndUpgradesInPlace(t *testing.T) {
8551007 setA, _ := encodeRecord(opSet, []byte("a"), []byte("one"))
8561008 setB, _ := encodeRecord(opSet, []byte("b"), []byte("two"))
8571009 replaceA, _ := encodeRecord(opSet, []byte("a"), []byte("uno"))
8581010 delB, _ := encodeRecord(opDel, []byte("b"), nil)
8591011 torn, _ := encodeRecord(opSet, []byte("c"), []byte("three"))
860-
8611012 tests := []struct {
8621013 name string
8631014 wal []byte
@@ -896,9 +1047,7 @@ func TestLegacyFileOpensAndUpgradesInPlace(t *testing.T) {
8961047 }
8971048 defer closeStore(t, reopened)
8981049 want := map[string]string{"added": "after-upgrade"}
899- for k, v := range tt.want {
900- want[k] = v
901- }
1050+ maps.Copy(want, tt.want)
9021051 requireContents(t, reopened, want)
9031052 replayed, err := Replay(path)
9041053 if err != nil {

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close