detached d6f7eb5

fix(persistence): complete strict catalogue integration

commit
d6f7eb521c70cc02221a4a2cdb0723a88de2b4d6
author
brice · · 2026-07-25 19:54 UTC
parent
9d96051
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +197 -35
M internal/app/run.go +1 -2
@@ -50,7 +50,6 @@ import (
5050 "github.com/bnema/vev/internal/usecase/confirm"
5151 "github.com/bnema/vev/internal/usecase/daemon"
5252 pdgram "github.com/bnema/vev/pkg/dgram"
53- "github.com/bnema/vev/pkg/kv"
5453 "github.com/bnema/vev/pkg/safedir"
5554 )
5655
@@ -519,7 +518,7 @@ func runDaemonOwned(ctx context.Context) (retErr error) {
519518 if !catalogueCandidate {
520519 return fmt.Errorf("vev: durable session catalogue unavailable at %s", storePath)
521520 }
522- store, err := kv.Open(storePath)
521+ store, err := persist.OpenStore(storePath)
523522 if err != nil {
524523 log.Error("opening session store failed", "path", storePath, "err", err)
525524 return fmt.Errorf("vev: open durable session catalogue %s: %w", storePath, err)
@@ -50,7 +50,6 @@ import (
50 "github.com/bnema/vev/internal/usecase/confirm"50 "github.com/bnema/vev/internal/usecase/confirm"
51 "github.com/bnema/vev/internal/usecase/daemon"51 "github.com/bnema/vev/internal/usecase/daemon"
52 pdgram "github.com/bnema/vev/pkg/dgram"52 pdgram "github.com/bnema/vev/pkg/dgram"
53- "github.com/bnema/vev/pkg/kv"
54 "github.com/bnema/vev/pkg/safedir"53 "github.com/bnema/vev/pkg/safedir"
55 )54 )
56 55
@@ -519,7 +518,7 @@ func runDaemonOwned(ctx context.Context) (retErr error) {
519 if !catalogueCandidate {518 if !catalogueCandidate {
520 return fmt.Errorf("vev: durable session catalogue unavailable at %s", storePath)519 return fmt.Errorf("vev: durable session catalogue unavailable at %s", storePath)
521 }520 }
522- store, err := kv.Open(storePath)521+ store, err := persist.OpenStore(storePath)
523 if err != nil {522 if err != nil {
524 log.Error("opening session store failed", "path", storePath, "err", err)523 log.Error("opening session store failed", "path", storePath, "err", err)
525 return fmt.Errorf("vev: open durable session catalogue %s: %w", storePath, err)524 return fmt.Errorf("vev: open durable session catalogue %s: %w", storePath, err)
M internal/persist/persist.go +28 -1
@@ -24,6 +24,33 @@ type Persister struct {
2424 mu sync.Mutex
2525 }
2626
27+// KVStore adapts the reusable WAL implementation to the persistence port.
28+type KVStore struct{ store *kv.Store }
29+
30+func OpenStore(path string) (*KVStore, error) {
31+ store, err := kv.Open(path)
32+ if err != nil {
33+ return nil, err
34+ }
35+ return &KVStore{store: store}, nil
36+}
37+
38+func (s *KVStore) Get(key []byte) ([]byte, bool) { return s.store.Get(key) }
39+func (s *KVStore) Set(key, value []byte) error { return s.store.Set(key, value) }
40+func (s *KVStore) Delete(key []byte) error { return s.store.Delete(key) }
41+func (s *KVStore) Range(fn func(key, value []byte) bool) {
42+ s.store.Range(fn)
43+}
44+func (s *KVStore) Sync() error { return s.store.Sync() }
45+func (s *KVStore) Close() error { return s.store.Close() }
46+func (s *KVStore) Batch(changes []ports.StoreChange) error {
47+ batch := make([]kv.BatchChange, len(changes))
48+ for i, change := range changes {
49+ batch[i] = kv.BatchChange{Key: change.Key, Value: change.Value, Delete: change.Delete}
50+ }
51+ return s.store.Batch(batch)
52+}
53+
2754 // Open opens an existing strict catalogue and never creates an unproven empty one.
2855 func Open(dir string) (*Persister, error) {
2956 p, _, err := openCurrentCatalogue(dir, false)
@@ -47,7 +74,7 @@ func openCurrentCatalogue(dir string, createProvenEmpty bool) (*Persister, []dom
4774 if absent && !createProvenEmpty {
4875 return nil, nil, fmt.Errorf("%w: no catalogue candidates", errPersistenceUnavailable)
4976 }
50- store, err := kv.Open(path)
77+ store, err := OpenStore(path)
5178 if err != nil {
5279 return nil, nil, err
5380 }
@@ -24,6 +24,33 @@ type Persister struct {
24 mu sync.Mutex24 mu sync.Mutex
25 }25 }
26 26
27+// KVStore adapts the reusable WAL implementation to the persistence port.
28+type KVStore struct{ store *kv.Store }
29+
30+func OpenStore(path string) (*KVStore, error) {
31+ store, err := kv.Open(path)
32+ if err != nil {
33+ return nil, err
34+ }
35+ return &KVStore{store: store}, nil
36+}
37+
38+func (s *KVStore) Get(key []byte) ([]byte, bool) { return s.store.Get(key) }
39+func (s *KVStore) Set(key, value []byte) error { return s.store.Set(key, value) }
40+func (s *KVStore) Delete(key []byte) error { return s.store.Delete(key) }
41+func (s *KVStore) Range(fn func(key, value []byte) bool) {
42+ s.store.Range(fn)
43+}
44+func (s *KVStore) Sync() error { return s.store.Sync() }
45+func (s *KVStore) Close() error { return s.store.Close() }
46+func (s *KVStore) Batch(changes []ports.StoreChange) error {
47+ batch := make([]kv.BatchChange, len(changes))
48+ for i, change := range changes {
49+ batch[i] = kv.BatchChange{Key: change.Key, Value: change.Value, Delete: change.Delete}
50+ }
51+ return s.store.Batch(batch)
52+}
53+
27 // Open opens an existing strict catalogue and never creates an unproven empty one.54 // Open opens an existing strict catalogue and never creates an unproven empty one.
28 func Open(dir string) (*Persister, error) {55 func Open(dir string) (*Persister, error) {
29 p, _, err := openCurrentCatalogue(dir, false)56 p, _, err := openCurrentCatalogue(dir, false)
@@ -47,7 +74,7 @@ func openCurrentCatalogue(dir string, createProvenEmpty bool) (*Persister, []dom
47 if absent && !createProvenEmpty {74 if absent && !createProvenEmpty {
48 return nil, nil, fmt.Errorf("%w: no catalogue candidates", errPersistenceUnavailable)75 return nil, nil, fmt.Errorf("%w: no catalogue candidates", errPersistenceUnavailable)
49 }76 }
50- store, err := kv.Open(path)77+ store, err := OpenStore(path)
51 if err != nil {78 if err != nil {
52 return nil, nil, err79 return nil, nil, err
53 }80 }
M internal/ports/mocks/store_mock.go +8 -8
@@ -39,8 +39,8 @@ func (_m *MockStore) EXPECT() *MockStore_Expecter {
3939 }
4040
4141 // Batch provides a mock function for the type MockStore
42-func (_mock *MockStore) Batch(vs []ports.StoreChange) error {
43- ret := _mock.Called(vs)
42+func (_mock *MockStore) Batch(storeChanges []ports.StoreChange) error {
43+ ret := _mock.Called(storeChanges)
4444
4545 if len(ret) == 0 {
4646 panic("no return value specified for Batch")
@@ -48,7 +48,7 @@ func (_mock *MockStore) Batch(vs []ports.StoreChange) error {
4848
4949 var r0 error
5050 if returnFunc, ok := ret.Get(0).(func([]ports.StoreChange) error); ok {
51- r0 = returnFunc(vs)
51+ r0 = returnFunc(storeChanges)
5252 } else {
5353 r0 = ret.Error(0)
5454 }
@@ -61,12 +61,12 @@ type MockStore_Batch_Call struct {
6161 }
6262
6363 // Batch is a helper method to define mock.On call
64-// - vs []ports.StoreChange
65-func (_e *MockStore_Expecter) Batch(vs any) *MockStore_Batch_Call {
66- return &MockStore_Batch_Call{Call: _e.mock.On("Batch", vs)}
64+// - storeChanges []ports.StoreChange
65+func (_e *MockStore_Expecter) Batch(storeChanges any) *MockStore_Batch_Call {
66+ return &MockStore_Batch_Call{Call: _e.mock.On("Batch", storeChanges)}
6767 }
6868
69-func (_c *MockStore_Batch_Call) Run(run func(vs []ports.StoreChange)) *MockStore_Batch_Call {
69+func (_c *MockStore_Batch_Call) Run(run func(storeChanges []ports.StoreChange)) *MockStore_Batch_Call {
7070 _c.Call.Run(func(args mock.Arguments) {
7171 var arg0 []ports.StoreChange
7272 if args[0] != nil {
@@ -84,7 +84,7 @@ func (_c *MockStore_Batch_Call) Return(err error) *MockStore_Batch_Call {
8484 return _c
8585 }
8686
87-func (_c *MockStore_Batch_Call) RunAndReturn(run func(vs []ports.StoreChange) error) *MockStore_Batch_Call {
87+func (_c *MockStore_Batch_Call) RunAndReturn(run func(storeChanges []ports.StoreChange) error) *MockStore_Batch_Call {
8888 _c.Call.Return(run)
8989 return _c
9090 }
@@ -39,8 +39,8 @@ func (_m *MockStore) EXPECT() *MockStore_Expecter {
39 }39 }
40 40
41 // Batch provides a mock function for the type MockStore41 // Batch provides a mock function for the type MockStore
42-func (_mock *MockStore) Batch(vs []ports.StoreChange) error {42+func (_mock *MockStore) Batch(storeChanges []ports.StoreChange) error {
43- ret := _mock.Called(vs)43+ ret := _mock.Called(storeChanges)
44 44
45 if len(ret) == 0 {45 if len(ret) == 0 {
46 panic("no return value specified for Batch")46 panic("no return value specified for Batch")
@@ -48,7 +48,7 @@ func (_mock *MockStore) Batch(vs []ports.StoreChange) error {
48 48
49 var r0 error49 var r0 error
50 if returnFunc, ok := ret.Get(0).(func([]ports.StoreChange) error); ok {50 if returnFunc, ok := ret.Get(0).(func([]ports.StoreChange) error); ok {
51- r0 = returnFunc(vs)51+ r0 = returnFunc(storeChanges)
52 } else {52 } else {
53 r0 = ret.Error(0)53 r0 = ret.Error(0)
54 }54 }
@@ -61,12 +61,12 @@ type MockStore_Batch_Call struct {
61 }61 }
62 62
63 // Batch is a helper method to define mock.On call63 // Batch is a helper method to define mock.On call
64-// - vs []ports.StoreChange64+// - storeChanges []ports.StoreChange
65-func (_e *MockStore_Expecter) Batch(vs any) *MockStore_Batch_Call {65+func (_e *MockStore_Expecter) Batch(storeChanges any) *MockStore_Batch_Call {
66- return &MockStore_Batch_Call{Call: _e.mock.On("Batch", vs)}66+ return &MockStore_Batch_Call{Call: _e.mock.On("Batch", storeChanges)}
67 }67 }
68 68
69-func (_c *MockStore_Batch_Call) Run(run func(vs []ports.StoreChange)) *MockStore_Batch_Call {69+func (_c *MockStore_Batch_Call) Run(run func(storeChanges []ports.StoreChange)) *MockStore_Batch_Call {
70 _c.Call.Run(func(args mock.Arguments) {70 _c.Call.Run(func(args mock.Arguments) {
71 var arg0 []ports.StoreChange71 var arg0 []ports.StoreChange
72 if args[0] != nil {72 if args[0] != nil {
@@ -84,7 +84,7 @@ func (_c *MockStore_Batch_Call) Return(err error) *MockStore_Batch_Call {
84 return _c84 return _c
85 }85 }
86 86
87-func (_c *MockStore_Batch_Call) RunAndReturn(run func(vs []ports.StoreChange) error) *MockStore_Batch_Call {87+func (_c *MockStore_Batch_Call) RunAndReturn(run func(storeChanges []ports.StoreChange) error) *MockStore_Batch_Call {
88 _c.Call.Return(run)88 _c.Call.Return(run)
89 return _c89 return _c
90 }90 }
M internal/ports/store.go +5 -3
@@ -3,12 +3,14 @@ package ports
33 import (
44 "context"
55 "crypto/sha256"
6-
7- "github.com/bnema/vev/pkg/kv"
86 )
97
108 // StoreChange is one key mutation in an atomic store batch.
11-type StoreChange = kv.BatchChange
9+type StoreChange struct {
10+ Key []byte
11+ Value []byte
12+ Delete bool
13+}
1214
1315 // Store is a small byte-key/value persistence port.
1416 //
@@ -3,12 +3,14 @@ package ports
3 import (3 import (
4 "context"4 "context"
5 "crypto/sha256"5 "crypto/sha256"
6-
7- "github.com/bnema/vev/pkg/kv"
8 )6 )
9 7
10 // StoreChange is one key mutation in an atomic store batch.8 // StoreChange is one key mutation in an atomic store batch.
11-type StoreChange = kv.BatchChange9+type StoreChange struct {
10+ Key []byte
11+ Value []byte
12+ Delete bool
13+}
12 14
13 // Store is a small byte-key/value persistence port.15 // Store is a small byte-key/value persistence port.
14 //16 //
M internal/usecase/daemon/daemon.go +2 -1
@@ -212,6 +212,7 @@ type stoppedSession struct {
212212 name string
213213 cwd string
214214 createdAt int64
215+ incarnation domain.IncarnationID
215216 lastUsedSeq uint64
216217 tabNames []string
217218 purging bool
@@ -419,7 +420,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
419420 var maxCreatedAt int64
420421 hasCreatedAt := false
421422 for _, r := range records {
422- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames}
423+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, incarnation: r.IncarnationID, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames}
423424 if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
424425 maxCreatedAt = r.CreatedAt
425426 hasCreatedAt = true
@@ -212,6 +212,7 @@ type stoppedSession struct {
212 name string212 name string
213 cwd string213 cwd string
214 createdAt int64214 createdAt int64
215+ incarnation domain.IncarnationID
215 lastUsedSeq uint64216 lastUsedSeq uint64
216 tabNames []string217 tabNames []string
217 purging bool218 purging bool
@@ -419,7 +420,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
419 var maxCreatedAt int64420 var maxCreatedAt int64
420 hasCreatedAt := false421 hasCreatedAt := false
421 for _, r := range records {422 for _, r := range records {
422- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames}423+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, incarnation: r.IncarnationID, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames}
423 if !hasCreatedAt || r.CreatedAt > maxCreatedAt {424 if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
424 maxCreatedAt = r.CreatedAt425 maxCreatedAt = r.CreatedAt
425 hasCreatedAt = true426 hasCreatedAt = true
M internal/usecase/daemon/picker_test.go +10 -2
@@ -28,8 +28,16 @@ import (
2828 // generated mock's default Delete expectation.
2929 type failingDeleteStore struct{ err error }
3030
31-func (failingDeleteStore) Get([]byte) ([]byte, bool) { return nil, false }
32-func (failingDeleteStore) Set([]byte, []byte) error { return nil }
31+func (failingDeleteStore) Get([]byte) ([]byte, bool) { return nil, false }
32+func (failingDeleteStore) Set([]byte, []byte) error { return nil }
33+func (s failingDeleteStore) Batch(changes []ports.StoreChange) error {
34+ for _, change := range changes {
35+ if change.Delete {
36+ return s.err
37+ }
38+ }
39+ return nil
40+}
3341 func (s failingDeleteStore) Delete([]byte) error { return s.err }
3442 func (failingDeleteStore) Range(func(k, v []byte) bool) {}
3543 func (failingDeleteStore) Sync() error { return nil }
@@ -28,8 +28,16 @@ import (
28 // generated mock's default Delete expectation.28 // generated mock's default Delete expectation.
29 type failingDeleteStore struct{ err error }29 type failingDeleteStore struct{ err error }
30 30
31-func (failingDeleteStore) Get([]byte) ([]byte, bool) { return nil, false }31+func (failingDeleteStore) Get([]byte) ([]byte, bool) { return nil, false }
32-func (failingDeleteStore) Set([]byte, []byte) error { return nil }32+func (failingDeleteStore) Set([]byte, []byte) error { return nil }
33+func (s failingDeleteStore) Batch(changes []ports.StoreChange) error {
34+ for _, change := range changes {
35+ if change.Delete {
36+ return s.err
37+ }
38+ }
39+ return nil
40+}
33 func (s failingDeleteStore) Delete([]byte) error { return s.err }41 func (s failingDeleteStore) Delete([]byte) error { return s.err }
34 func (failingDeleteStore) Range(func(k, v []byte) bool) {}42 func (failingDeleteStore) Range(func(k, v []byte) bool) {}
35 func (failingDeleteStore) Sync() error { return nil }43 func (failingDeleteStore) Sync() error { return nil }
M internal/usecase/daemon/session.go +21 -3
@@ -3,6 +3,7 @@ package daemon
33
44 import (
55 "context"
6+ "crypto/rand"
67 "errors"
78 "fmt"
89 "os"
@@ -66,6 +67,7 @@ type session struct {
6667 // It is guarded by mu and is always copied on ingress and egress.
6768 env []string
6869 createdAt int64
70+ incarnation domain.IncarnationID
6971 mruAt atomic.Uint64
7072 snapDirty atomic.Bool
7173 snapEligible atomic.Bool
@@ -193,11 +195,13 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
193195 env = copyEnvironment(env)
194196 stopped, resuming := d.stopped[name]
195197 var createdAt int64
198+ var incarnation domain.IncarnationID
196199 if !ephemeral {
197200 if resuming {
198201 // A stopped session is the same lifecycle when resumed; it must retain
199202 // the persisted identity rather than receive a fresh timestamp.
200203 createdAt = stopped.createdAt
204+ incarnation = stopped.incarnation
201205 if createdAt > d.lastAllocatedCreatedAt {
202206 d.lastAllocatedCreatedAt = createdAt
203207 }
@@ -208,6 +212,13 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
208212 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
209213 }
210214 }
215+ if incarnation == (domain.IncarnationID{}) {
216+ var err error
217+ incarnation, err = domain.NewIncarnationID(rand.Reader)
218+ if err != nil {
219+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", fmt.Errorf("generate durable identity: %w", err))
220+ }
221+ }
211222 }
212223
213224 tbSize := tabSize(sz)
@@ -258,6 +269,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
258269 terminal: term,
259270 env: env,
260271 createdAt: createdAt,
272+ incarnation: incarnation,
261273 snapshotWake: d.snapshotWake,
262274 }
263275 if !ephemeral && name != "" {
@@ -268,7 +280,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
268280 }
269281 sess.snapEligible.Store(!ephemeral && name != "")
270282 if !ephemeral {
271- if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}); err != nil {
283+ if err := d.persist.Save(persist.Record{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names, RecoveryState: domain.RecoveryFresh}); err != nil {
272284 closeTabs(tabs)
273285 cancel()
274286 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
@@ -643,6 +655,12 @@ func (d *Daemon) renameSession(sess *session, name string) error {
643655 d.mu.Unlock()
644656 return err
645657 }
658+ sess.incarnation, err = domain.NewIncarnationID(rand.Reader)
659+ if err != nil {
660+ sess.mu.Unlock()
661+ d.mu.Unlock()
662+ return fmt.Errorf("generate durable identity: %w", err)
663+ }
646664 }
647665 lastUsedSeq := sess.mruAt.Load()
648666 record := sess.persistRecordLocked(d.nowUnixNano())
@@ -764,7 +782,7 @@ func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
764782 } else {
765783 tabNames = tabNames[:lastCustom+1]
766784 }
767- return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}
785+ 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}
768786 }
769787
770788 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
@@ -1145,7 +1163,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
11451163 ephemeral := sess.ephemeral
11461164 sess.mu.Unlock()
11471165 if !ephemeral {
1148- stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
1166+ stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: sess.incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
11491167 d.stopped[stoppedName] = stopped
11501168 }
11511169 empty := len(d.sessions) == 0
@@ -3,6 +3,7 @@ package daemon
3 3
4 import (4 import (
5 "context"5 "context"
6+ "crypto/rand"
6 "errors"7 "errors"
7 "fmt"8 "fmt"
8 "os"9 "os"
@@ -66,6 +67,7 @@ type session struct {
66 // It is guarded by mu and is always copied on ingress and egress.67 // It is guarded by mu and is always copied on ingress and egress.
67 env []string68 env []string
68 createdAt int6469 createdAt int64
70+ incarnation domain.IncarnationID
69 mruAt atomic.Uint6471 mruAt atomic.Uint64
70 snapDirty atomic.Bool72 snapDirty atomic.Bool
71 snapEligible atomic.Bool73 snapEligible atomic.Bool
@@ -193,11 +195,13 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
193 env = copyEnvironment(env)195 env = copyEnvironment(env)
194 stopped, resuming := d.stopped[name]196 stopped, resuming := d.stopped[name]
195 var createdAt int64197 var createdAt int64
198+ var incarnation domain.IncarnationID
196 if !ephemeral {199 if !ephemeral {
197 if resuming {200 if resuming {
198 // A stopped session is the same lifecycle when resumed; it must retain201 // A stopped session is the same lifecycle when resumed; it must retain
199 // the persisted identity rather than receive a fresh timestamp.202 // the persisted identity rather than receive a fresh timestamp.
200 createdAt = stopped.createdAt203 createdAt = stopped.createdAt
204+ incarnation = stopped.incarnation
201 if createdAt > d.lastAllocatedCreatedAt {205 if createdAt > d.lastAllocatedCreatedAt {
202 d.lastAllocatedCreatedAt = createdAt206 d.lastAllocatedCreatedAt = createdAt
203 }207 }
@@ -208,6 +212,13 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
208 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)212 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
209 }213 }
210 }214 }
215+ if incarnation == (domain.IncarnationID{}) {
216+ var err error
217+ incarnation, err = domain.NewIncarnationID(rand.Reader)
218+ if err != nil {
219+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", fmt.Errorf("generate durable identity: %w", err))
220+ }
221+ }
211 }222 }
212 223
213 tbSize := tabSize(sz)224 tbSize := tabSize(sz)
@@ -258,6 +269,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
258 terminal: term,269 terminal: term,
259 env: env,270 env: env,
260 createdAt: createdAt,271 createdAt: createdAt,
272+ incarnation: incarnation,
261 snapshotWake: d.snapshotWake,273 snapshotWake: d.snapshotWake,
262 }274 }
263 if !ephemeral && name != "" {275 if !ephemeral && name != "" {
@@ -268,7 +280,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
268 }280 }
269 sess.snapEligible.Store(!ephemeral && name != "")281 sess.snapEligible.Store(!ephemeral && name != "")
270 if !ephemeral {282 if !ephemeral {
271- if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}); err != nil {283+ if err := d.persist.Save(persist.Record{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names, RecoveryState: domain.RecoveryFresh}); err != nil {
272 closeTabs(tabs)284 closeTabs(tabs)
273 cancel()285 cancel()
274 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)286 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
@@ -643,6 +655,12 @@ func (d *Daemon) renameSession(sess *session, name string) error {
643 d.mu.Unlock()655 d.mu.Unlock()
644 return err656 return err
645 }657 }
658+ sess.incarnation, err = domain.NewIncarnationID(rand.Reader)
659+ if err != nil {
660+ sess.mu.Unlock()
661+ d.mu.Unlock()
662+ return fmt.Errorf("generate durable identity: %w", err)
663+ }
646 }664 }
647 lastUsedSeq := sess.mruAt.Load()665 lastUsedSeq := sess.mruAt.Load()
648 record := sess.persistRecordLocked(d.nowUnixNano())666 record := sess.persistRecordLocked(d.nowUnixNano())
@@ -764,7 +782,7 @@ func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
764 } else {782 } else {
765 tabNames = tabNames[:lastCustom+1]783 tabNames = tabNames[:lastCustom+1]
766 }784 }
767- return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}785+ 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}
768 }786 }
769 787
770 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {788 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
@@ -1145,7 +1163,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1145 ephemeral := sess.ephemeral1163 ephemeral := sess.ephemeral
1146 sess.mu.Unlock()1164 sess.mu.Unlock()
1147 if !ephemeral {1165 if !ephemeral {
1148- stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}1166+ stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, incarnation: sess.incarnation, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
1149 d.stopped[stoppedName] = stopped1167 d.stopped[stoppedName] = stopped
1150 }1168 }
1151 empty := len(d.sessions) == 01169 empty := len(d.sessions) == 0
M internal/usecase/daemon/session_test.go +53 -11
@@ -701,6 +701,26 @@ func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
701701 state.data[string(k)] = append([]byte(nil), v...)
702702 return nil
703703 }).Maybe()
704+ store.EXPECT().Batch(mock.Anything).RunAndReturn(func(changes []ports.StoreChange) error {
705+ state.mu.Lock()
706+ defer state.mu.Unlock()
707+ for _, change := range changes {
708+ key := string(change.Key)
709+ if change.Delete {
710+ state.dels = append(state.dels, key)
711+ if state.deleteErr != nil {
712+ if err := state.deleteErr(key); err != nil {
713+ return err
714+ }
715+ }
716+ delete(state.data, key)
717+ continue
718+ }
719+ state.sets++
720+ state.data[key] = append([]byte(nil), change.Value...)
721+ }
722+ return nil
723+ }).Maybe()
704724 store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error {
705725 state.mu.Lock()
706726 key := string(k)
@@ -774,6 +794,16 @@ func (s newStaticStore) Get(key []byte) ([]byte, bool) {
774794 }
775795 func (s newStaticStore) Set(_, _ []byte) error { return nil }
776796 func (s newStaticStore) Delete(_ []byte) error { return nil }
797+func (s newStaticStore) Batch(changes []ports.StoreChange) error {
798+ for _, change := range changes {
799+ if change.Delete {
800+ delete(s, string(change.Key))
801+ } else {
802+ s[string(change.Key)] = append([]byte(nil), change.Value...)
803+ }
804+ }
805+ return nil
806+}
777807 func (s newStaticStore) Range(fn func(k, v []byte) bool) {
778808 for k, v := range s {
779809 if !fn([]byte(k), append([]byte(nil), v...)) {
@@ -787,13 +817,14 @@ func (s newStaticStore) Close() error { return nil }
787817 func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
788818 store, _ := newMockStore(t)
789819 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
790- require.NoError(t, seed.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9}))
820+ incarnation := domain.IncarnationID{1}
821+ require.NoError(t, seed.persist.Save(persist.Record{Name: "work", IncarnationID: incarnation, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9, RecoveryState: domain.RecoveryFresh}))
791822
792823 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
793824 d.mu.Lock()
794825 stopped := d.stopped["work"]
795826 d.mu.Unlock()
796- require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, lastUsedSeq: 9}, stopped)
827+ require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, incarnation: incarnation, lastUsedSeq: 9}, stopped)
797828 require.Equal(t, uint64(9), d.mruSeq.Load())
798829 }
799830
@@ -846,8 +877,8 @@ func TestTouchMRUPersistsNamedButNotEphemeral(t *testing.T) {
846877 store, state := newMockStore(t)
847878 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
848879 WithStore(store)(d)
849- named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1}
850- require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))
880+ named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1, incarnation: domain.IncarnationID{1}}
881+ require.NoError(t, d.persist.Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryFresh}))
851882
852883 d.touchMRU(named)
853884 require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq)
@@ -888,6 +919,10 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
888919 sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
889920 require.NoError(t, err)
890921 require.True(t, state.has("work"))
922+ created := state.record(t, "work")
923+ require.NotEqual(t, domain.IncarnationID{}, created.IncarnationID)
924+ require.Equal(t, domain.RecoveryFresh, created.RecoveryState)
925+ require.NoError(t, created.Validate())
891926 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
892927 require.NoError(t, d.createTab(sess, sz))
893928 require.NoError(t, d.renameTab(sess, sess.tabs[1], "logs"))
@@ -895,7 +930,10 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
895930 require.NoError(t, d.renameSession(sess, "renamed"))
896931 require.False(t, state.has("work"))
897932 require.True(t, state.has("renamed"))
898- require.Equal(t, []string{"shell", "logs"}, state.record(t, "renamed").TabNames)
933+ renamed := state.record(t, "renamed")
934+ require.Equal(t, []string{"shell", "logs"}, renamed.TabNames)
935+ require.Equal(t, created.IncarnationID, renamed.IncarnationID)
936+ require.NoError(t, renamed.Validate())
899937
900938 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
901939 require.False(t, state.has("renamed"))
@@ -1014,7 +1052,7 @@ func TestAttachRestoresPersistedTabNames(t *testing.T) {
10141052 store, _ := newMockStore(t)
10151053 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
10161054 WithStore(store)(d)
1017- require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}}))
1055+ 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}))
10181056 d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
10191057 tr := portsmocks.NewMockTransport(t)
10201058 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
@@ -1123,8 +1161,12 @@ func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T)
11231161 defer release()
11241162 store := portsmocks.NewMockStore(t)
11251163 var attempted map[string][]byte
1126- store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(key, value []byte) error {
1127- attempted = map[string][]byte{string(key): append([]byte(nil), value...)}
1164+ store.EXPECT().Range(mock.Anything).Run(func(func([]byte, []byte) bool) {}).Twice()
1165+ store.EXPECT().Batch(mock.Anything).RunAndReturn(func(changes []ports.StoreChange) error {
1166+ attempted = make(map[string][]byte, len(changes))
1167+ for _, change := range changes {
1168+ attempted[string(change.Key)] = append([]byte(nil), change.Value...)
1169+ }
11281170 return errors.New("disk full")
11291171 }).Once()
11301172 clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}}
@@ -1143,7 +1185,7 @@ func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T)
11431185 require.Len(t, records, 1)
11441186 require.Equal(t, int64(100), records[0].CreatedAt, "a promotion must never attempt to persist a zero lifecycle identity")
11451187
1146- store.EXPECT().Set(mock.Anything, mock.Anything).Return(nil).Once()
1188+ store.EXPECT().Batch(mock.Anything).Return(nil).Once()
11471189 store.EXPECT().Sync().Return(nil).Once()
11481190 require.NoError(t, d.renameSession(sess, "named"))
11491191 require.Equal(t, int64(101), sess.createdAt, "the high-water mark must remain monotonic after a failed promotion")
@@ -1223,7 +1265,7 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
12231265 store, state := newMockStore(t)
12241266 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
12251267 WithStore(store)(d)
1226- require.NoError(t, d.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
1268+ require.NoError(t, d.persist.Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryFresh}))
12271269 d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1}
12281270 require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true}))
12291271 require.False(t, state.has("old"))
@@ -1703,7 +1745,7 @@ func TestNamedSessionLifecycleExhaustionDoesNotMutateSessionState(t *testing.T)
17031745 func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) {
17041746 store, _ := newMockStore(t)
17051747 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
1706- require.NoError(t, seed.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900}))
1748+ require.NoError(t, seed.persist.Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900, RecoveryState: domain.RecoveryFresh}))
17071749
17081750 p, release := newBlockingPTY(t)
17091751 defer release()
@@ -701,6 +701,26 @@ func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
701 state.data[string(k)] = append([]byte(nil), v...)701 state.data[string(k)] = append([]byte(nil), v...)
702 return nil702 return nil
703 }).Maybe()703 }).Maybe()
704+ store.EXPECT().Batch(mock.Anything).RunAndReturn(func(changes []ports.StoreChange) error {
705+ state.mu.Lock()
706+ defer state.mu.Unlock()
707+ for _, change := range changes {
708+ key := string(change.Key)
709+ if change.Delete {
710+ state.dels = append(state.dels, key)
711+ if state.deleteErr != nil {
712+ if err := state.deleteErr(key); err != nil {
713+ return err
714+ }
715+ }
716+ delete(state.data, key)
717+ continue
718+ }
719+ state.sets++
720+ state.data[key] = append([]byte(nil), change.Value...)
721+ }
722+ return nil
723+ }).Maybe()
704 store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error {724 store.EXPECT().Delete(mock.Anything).RunAndReturn(func(k []byte) error {
705 state.mu.Lock()725 state.mu.Lock()
706 key := string(k)726 key := string(k)
@@ -774,6 +794,16 @@ func (s newStaticStore) Get(key []byte) ([]byte, bool) {
774 }794 }
775 func (s newStaticStore) Set(_, _ []byte) error { return nil }795 func (s newStaticStore) Set(_, _ []byte) error { return nil }
776 func (s newStaticStore) Delete(_ []byte) error { return nil }796 func (s newStaticStore) Delete(_ []byte) error { return nil }
797+func (s newStaticStore) Batch(changes []ports.StoreChange) error {
798+ for _, change := range changes {
799+ if change.Delete {
800+ delete(s, string(change.Key))
801+ } else {
802+ s[string(change.Key)] = append([]byte(nil), change.Value...)
803+ }
804+ }
805+ return nil
806+}
777 func (s newStaticStore) Range(fn func(k, v []byte) bool) {807 func (s newStaticStore) Range(fn func(k, v []byte) bool) {
778 for k, v := range s {808 for k, v := range s {
779 if !fn([]byte(k), append([]byte(nil), v...)) {809 if !fn([]byte(k), append([]byte(nil), v...)) {
@@ -787,13 +817,14 @@ func (s newStaticStore) Close() error { return nil }
787 func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {817 func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
788 store, _ := newMockStore(t)818 store, _ := newMockStore(t)
789 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))819 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
790- require.NoError(t, seed.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9}))820+ incarnation := domain.IncarnationID{1}
821+ require.NoError(t, seed.persist.Save(persist.Record{Name: "work", IncarnationID: incarnation, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9, RecoveryState: domain.RecoveryFresh}))
791 822
792 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))823 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
793 d.mu.Lock()824 d.mu.Lock()
794 stopped := d.stopped["work"]825 stopped := d.stopped["work"]
795 d.mu.Unlock()826 d.mu.Unlock()
796- require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, lastUsedSeq: 9}, stopped)827+ require.Equal(t, stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, incarnation: incarnation, lastUsedSeq: 9}, stopped)
797 require.Equal(t, uint64(9), d.mruSeq.Load())828 require.Equal(t, uint64(9), d.mruSeq.Load())
798 }829 }
799 830
@@ -846,8 +877,8 @@ func TestTouchMRUPersistsNamedButNotEphemeral(t *testing.T) {
846 store, state := newMockStore(t)877 store, state := newMockStore(t)
847 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})878 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
848 WithStore(store)(d)879 WithStore(store)(d)
849- named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1}880+ named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1, incarnation: domain.IncarnationID{1}}
850- require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))881+ require.NoError(t, d.persist.Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryFresh}))
851 882
852 d.touchMRU(named)883 d.touchMRU(named)
853 require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq)884 require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq)
@@ -888,6 +919,10 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
888 sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)919 sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}, d.baseEnv)
889 require.NoError(t, err)920 require.NoError(t, err)
890 require.True(t, state.has("work"))921 require.True(t, state.has("work"))
922+ created := state.record(t, "work")
923+ require.NotEqual(t, domain.IncarnationID{}, created.IncarnationID)
924+ require.Equal(t, domain.RecoveryFresh, created.RecoveryState)
925+ require.NoError(t, created.Validate())
891 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))926 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
892 require.NoError(t, d.createTab(sess, sz))927 require.NoError(t, d.createTab(sess, sz))
893 require.NoError(t, d.renameTab(sess, sess.tabs[1], "logs"))928 require.NoError(t, d.renameTab(sess, sess.tabs[1], "logs"))
@@ -895,7 +930,10 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
895 require.NoError(t, d.renameSession(sess, "renamed"))930 require.NoError(t, d.renameSession(sess, "renamed"))
896 require.False(t, state.has("work"))931 require.False(t, state.has("work"))
897 require.True(t, state.has("renamed"))932 require.True(t, state.has("renamed"))
898- require.Equal(t, []string{"shell", "logs"}, state.record(t, "renamed").TabNames)933+ renamed := state.record(t, "renamed")
934+ require.Equal(t, []string{"shell", "logs"}, renamed.TabNames)
935+ require.Equal(t, created.IncarnationID, renamed.IncarnationID)
936+ require.NoError(t, renamed.Validate())
899 937
900 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))938 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
901 require.False(t, state.has("renamed"))939 require.False(t, state.has("renamed"))
@@ -1014,7 +1052,7 @@ func TestAttachRestoresPersistedTabNames(t *testing.T) {
1014 store, _ := newMockStore(t)1052 store, _ := newMockStore(t)
1015 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})1053 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
1016 WithStore(store)(d)1054 WithStore(store)(d)
1017- require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}}))1055+ 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}))
1018 d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}1056 d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
1019 tr := portsmocks.NewMockTransport(t)1057 tr := portsmocks.NewMockTransport(t)
1020 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()1058 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
@@ -1123,8 +1161,12 @@ func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T)
1123 defer release()1161 defer release()
1124 store := portsmocks.NewMockStore(t)1162 store := portsmocks.NewMockStore(t)
1125 var attempted map[string][]byte1163 var attempted map[string][]byte
1126- store.EXPECT().Set(mock.Anything, mock.Anything).RunAndReturn(func(key, value []byte) error {1164+ store.EXPECT().Range(mock.Anything).Run(func(func([]byte, []byte) bool) {}).Twice()
1127- attempted = map[string][]byte{string(key): append([]byte(nil), value...)}1165+ store.EXPECT().Batch(mock.Anything).RunAndReturn(func(changes []ports.StoreChange) error {
1166+ attempted = make(map[string][]byte, len(changes))
1167+ for _, change := range changes {
1168+ attempted[string(change.Key)] = append([]byte(nil), change.Value...)
1169+ }
1128 return errors.New("disk full")1170 return errors.New("disk full")
1129 }).Once()1171 }).Once()
1130 clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}}1172 clock := &lifecycleClock{nows: []time.Time{time.Unix(0, 100), time.Unix(0, 100)}}
@@ -1143,7 +1185,7 @@ func TestEphemeralPromotionLifecycleFailuresLeaveStateRollbackSafe(t *testing.T)
1143 require.Len(t, records, 1)1185 require.Len(t, records, 1)
1144 require.Equal(t, int64(100), records[0].CreatedAt, "a promotion must never attempt to persist a zero lifecycle identity")1186 require.Equal(t, int64(100), records[0].CreatedAt, "a promotion must never attempt to persist a zero lifecycle identity")
1145 1187
1146- store.EXPECT().Set(mock.Anything, mock.Anything).Return(nil).Once()1188+ store.EXPECT().Batch(mock.Anything).Return(nil).Once()
1147 store.EXPECT().Sync().Return(nil).Once()1189 store.EXPECT().Sync().Return(nil).Once()
1148 require.NoError(t, d.renameSession(sess, "named"))1190 require.NoError(t, d.renameSession(sess, "named"))
1149 require.Equal(t, int64(101), sess.createdAt, "the high-water mark must remain monotonic after a failed promotion")1191 require.Equal(t, int64(101), sess.createdAt, "the high-water mark must remain monotonic after a failed promotion")
@@ -1223,7 +1265,7 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
1223 store, state := newMockStore(t)1265 store, state := newMockStore(t)
1224 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})1266 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
1225 WithStore(store)(d)1267 WithStore(store)(d)
1226- require.NoError(t, d.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))1268+ require.NoError(t, d.persist.Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryFresh}))
1227 d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1}1269 d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1}
1228 require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true}))1270 require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true}))
1229 require.False(t, state.has("old"))1271 require.False(t, state.has("old"))
@@ -1703,7 +1745,7 @@ func TestNamedSessionLifecycleExhaustionDoesNotMutateSessionState(t *testing.T)
1703 func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) {1745 func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) {
1704 store, _ := newMockStore(t)1746 store, _ := newMockStore(t)
1705 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))1747 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store))
1706- require.NoError(t, seed.persist.Save(persist.Record{Name: "old", Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900}))1748+ require.NoError(t, seed.persist.Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900, RecoveryState: domain.RecoveryFresh}))
1707 1749
1708 p, release := newBlockingPTY(t)1750 p, release := newBlockingPTY(t)
1709 defer release()1751 defer release()
M internal/usecase/daemon/snapshot_restore.go +14 -0
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "context"
5+ "crypto/rand"
56 "fmt"
67 "math"
78
@@ -253,6 +254,19 @@ func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context
253254 // persistAndRegisterRestoredSession atomically transfers a successfully
254255 // persisted session to the daemon. The caller retains cleanup on false/error.
255256 func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *session) (bool, error) {
257+ if sess.incarnation == (domain.IncarnationID{}) {
258+ d.mu.Lock()
259+ stopped := d.stopped[sess.name]
260+ d.mu.Unlock()
261+ sess.incarnation = stopped.incarnation
262+ if sess.incarnation == (domain.IncarnationID{}) {
263+ var err error
264+ sess.incarnation, err = domain.NewIncarnationID(rand.Reader)
265+ if err != nil {
266+ return false, fmt.Errorf("snapshot: generate durable identity: %w", err)
267+ }
268+ }
269+ }
256270 if err := d.persist.Save(sess.persistRecordLocked(sess.createdAt)); err != nil {
257271 return false, err
258272 }
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5+ "crypto/rand"
5 "fmt"6 "fmt"
6 "math"7 "math"
7 8
@@ -253,6 +254,19 @@ func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context
253 // persistAndRegisterRestoredSession atomically transfers a successfully254 // persistAndRegisterRestoredSession atomically transfers a successfully
254 // persisted session to the daemon. The caller retains cleanup on false/error.255 // persisted session to the daemon. The caller retains cleanup on false/error.
255 func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *session) (bool, error) {256 func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *session) (bool, error) {
257+ if sess.incarnation == (domain.IncarnationID{}) {
258+ d.mu.Lock()
259+ stopped := d.stopped[sess.name]
260+ d.mu.Unlock()
261+ sess.incarnation = stopped.incarnation
262+ if sess.incarnation == (domain.IncarnationID{}) {
263+ var err error
264+ sess.incarnation, err = domain.NewIncarnationID(rand.Reader)
265+ if err != nil {
266+ return false, fmt.Errorf("snapshot: generate durable identity: %w", err)
267+ }
268+ }
269+ }
256 if err := d.persist.Save(sess.persistRecordLocked(sess.createdAt)); err != nil {270 if err := d.persist.Save(sess.persistRecordLocked(sess.createdAt)); err != nil {
257 return false, err271 return false, err
258 }272 }
M internal/usecase/daemon/snapshot_test.go +3 -0
@@ -46,6 +46,9 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
4646 p.screen.Write([]byte("hello"))
4747 appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
4848 sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42}
49+ if !ephemeral {
50+ sess.incarnation = domain.IncarnationID{1}
51+ }
4952 sess.snapEligible.Store(!ephemeral && name != "")
5053 return sess
5154 }
@@ -46,6 +46,9 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
46 p.screen.Write([]byte("hello"))46 p.screen.Write([]byte("hello"))
47 appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})47 appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
48 sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42}48 sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42}
49+ if !ephemeral {
50+ sess.incarnation = domain.IncarnationID{1}
51+ }
49 sess.snapEligible.Store(!ephemeral && name != "")52 sess.snapEligible.Store(!ephemeral && name != "")
50 return sess53 return sess
51 }54 }
M pkg/kv/kv.go +23 -1
@@ -241,7 +241,12 @@ func (s *Store) shouldCompact() bool {
241241 return s.total > compactThreshold && float64(s.total-s.live)/float64(s.total) > compactWasteRatio
242242 }
243243
244-func (s *Store) compactLocked() error {
244+func (s *Store) compactLocked() (retErr error) {
245+ defer func() {
246+ if retErr != nil {
247+ retErr = errors.Join(retErr, s.poisonLocked())
248+ }
249+ }()
245250 next, prev := s.path+".next", s.path+".prev"
246251 if err := injectFault(s.hooks, "before-write-next"); err != nil {
247252 return err
@@ -369,6 +374,23 @@ func (s *Store) compactLocked() error {
369374 return injectFault(s.hooks, "after-final-sync-dir")
370375 }
371376
377+// poisonLocked permanently disables a store whose compaction did not complete.
378+// The caller holds s.mu, so no later append can retain a descriptor for a
379+// transitional pathname.
380+func (s *Store) poisonLocked() error {
381+ if s.closed {
382+ return nil
383+ }
384+ s.closed = true
385+ file, lockFile := s.file, s.lockFile
386+ s.file, s.lockFile = nil, nil
387+ var fileErr error
388+ if file != nil {
389+ fileErr = file.Close()
390+ }
391+ return errors.Join(fileErr, releaseLock(lockFile))
392+}
393+
372394 func replayFile(f *os.File) (ReplayResult, error) {
373395 info, err := f.Stat()
374396 if err != nil {
@@ -241,7 +241,12 @@ func (s *Store) shouldCompact() bool {
241 return s.total > compactThreshold && float64(s.total-s.live)/float64(s.total) > compactWasteRatio241 return s.total > compactThreshold && float64(s.total-s.live)/float64(s.total) > compactWasteRatio
242 }242 }
243 243
244-func (s *Store) compactLocked() error {244+func (s *Store) compactLocked() (retErr error) {
245+ defer func() {
246+ if retErr != nil {
247+ retErr = errors.Join(retErr, s.poisonLocked())
248+ }
249+ }()
245 next, prev := s.path+".next", s.path+".prev"250 next, prev := s.path+".next", s.path+".prev"
246 if err := injectFault(s.hooks, "before-write-next"); err != nil {251 if err := injectFault(s.hooks, "before-write-next"); err != nil {
247 return err252 return err
@@ -369,6 +374,23 @@ func (s *Store) compactLocked() error {
369 return injectFault(s.hooks, "after-final-sync-dir")374 return injectFault(s.hooks, "after-final-sync-dir")
370 }375 }
371 376
377+// poisonLocked permanently disables a store whose compaction did not complete.
378+// The caller holds s.mu, so no later append can retain a descriptor for a
379+// transitional pathname.
380+func (s *Store) poisonLocked() error {
381+ if s.closed {
382+ return nil
383+ }
384+ s.closed = true
385+ file, lockFile := s.file, s.lockFile
386+ s.file, s.lockFile = nil, nil
387+ var fileErr error
388+ if file != nil {
389+ fileErr = file.Close()
390+ }
391+ return errors.Join(fileErr, releaseLock(lockFile))
392+}
393+
372 func replayFile(f *os.File) (ReplayResult, error) {394 func replayFile(f *os.File) (ReplayResult, error) {
373 info, err := f.Stat()395 info, err := f.Stat()
374 if err != nil {396 if err != nil {
M pkg/kv/kv_test.go +29 -3
@@ -7,6 +7,7 @@ import (
77 "hash/crc32"
88 "os"
99 "path/filepath"
10+ "reflect"
1011 "testing"
1112 )
1213
@@ -395,10 +396,11 @@ func TestCompactionRecoveryMatrix(t *testing.T) {
395396 if err := s.compactLocked(); err == nil {
396397 t.Fatalf("fault %q did not interrupt compaction", point)
397398 }
398- if s.file != nil {
399- _ = s.file.Close()
399+ beforeAppend := candidateBytes(t, path)
400+ if err := s.Set([]byte("later"), []byte("must-not-append")); !errors.Is(err, os.ErrClosed) {
401+ t.Fatalf("append after compaction fault = %v, want closed store", err)
400402 }
401- _ = releaseLock(s.lockFile)
403+ requireCandidateBytes(t, path, beforeAppend)
402404 if err := recoverCompaction(path, defaultFSHooks()); err != nil {
403405 t.Fatalf("recovery: %v", err)
404406 }
@@ -414,6 +416,30 @@ func TestCompactionRecoveryMatrix(t *testing.T) {
414416 }
415417 }
416418
419+func candidateBytes(t *testing.T, path string) map[string][]byte {
420+ t.Helper()
421+ got := make(map[string][]byte, 3)
422+ for _, suffix := range []string{"", ".next", ".prev"} {
423+ data, err := os.ReadFile(path + suffix)
424+ if errors.Is(err, os.ErrNotExist) {
425+ continue
426+ }
427+ if err != nil {
428+ t.Fatal(err)
429+ }
430+ got[suffix] = data
431+ }
432+ return got
433+}
434+
435+func requireCandidateBytes(t *testing.T, path string, want map[string][]byte) {
436+ t.Helper()
437+ got := candidateBytes(t, path)
438+ if !reflect.DeepEqual(got, want) {
439+ t.Fatalf("catalogue candidates changed after poisoned append: got %#v, want %#v", got, want)
440+ }
441+}
442+
417443 func corruptRecord(buf []byte, offset int) []byte { buf[offset] ^= 0xff; return buf }
418444 func rawRecord(payload []byte) []byte {
419445 buf := make([]byte, headerLen+len(payload))
@@ -7,6 +7,7 @@ import (
7 "hash/crc32"7 "hash/crc32"
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10+ "reflect"
10 "testing"11 "testing"
11 )12 )
12 13
@@ -395,10 +396,11 @@ func TestCompactionRecoveryMatrix(t *testing.T) {
395 if err := s.compactLocked(); err == nil {396 if err := s.compactLocked(); err == nil {
396 t.Fatalf("fault %q did not interrupt compaction", point)397 t.Fatalf("fault %q did not interrupt compaction", point)
397 }398 }
398- if s.file != nil {399+ beforeAppend := candidateBytes(t, path)
399- _ = s.file.Close()400+ if err := s.Set([]byte("later"), []byte("must-not-append")); !errors.Is(err, os.ErrClosed) {
401+ t.Fatalf("append after compaction fault = %v, want closed store", err)
400 }402 }
401- _ = releaseLock(s.lockFile)403+ requireCandidateBytes(t, path, beforeAppend)
402 if err := recoverCompaction(path, defaultFSHooks()); err != nil {404 if err := recoverCompaction(path, defaultFSHooks()); err != nil {
403 t.Fatalf("recovery: %v", err)405 t.Fatalf("recovery: %v", err)
404 }406 }
@@ -414,6 +416,30 @@ func TestCompactionRecoveryMatrix(t *testing.T) {
414 }416 }
415 }417 }
416 418
419+func candidateBytes(t *testing.T, path string) map[string][]byte {
420+ t.Helper()
421+ got := make(map[string][]byte, 3)
422+ for _, suffix := range []string{"", ".next", ".prev"} {
423+ data, err := os.ReadFile(path + suffix)
424+ if errors.Is(err, os.ErrNotExist) {
425+ continue
426+ }
427+ if err != nil {
428+ t.Fatal(err)
429+ }
430+ got[suffix] = data
431+ }
432+ return got
433+}
434+
435+func requireCandidateBytes(t *testing.T, path string, want map[string][]byte) {
436+ t.Helper()
437+ got := candidateBytes(t, path)
438+ if !reflect.DeepEqual(got, want) {
439+ t.Fatalf("catalogue candidates changed after poisoned append: got %#v, want %#v", got, want)
440+ }
441+}
442+
417 func corruptRecord(buf []byte, offset int) []byte { buf[offset] ^= 0xff; return buf }443 func corruptRecord(buf []byte, offset int) []byte { buf[offset] ^= 0xff; return buf }
418 func rawRecord(payload []byte) []byte {444 func rawRecord(payload []byte) []byte {
419 buf := make([]byte, headerLen+len(payload))445 buf := make([]byte, headerLen+len(payload))

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