detached 5992754

fix(daemon): address snapshot review findings

commit
5992754ab06b20da3e3122b3c4070b54324a85bc
author
brice · · 2026-07-06 06:11 UTC
parent
6454fbc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +132 -22
M internal/adapters/snapshot/store.go +2 -2
@@ -95,7 +95,7 @@ func (s *Store) Load() ([]ports.SnapshotBlob, error) {
9595 path := filepath.Join(s.dir, base)
9696 if strings.HasPrefix(base, ".tmp-") {
9797 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
98- s.log.Warn("remove stale snapshot temp", "path", path, "error", err)
98+ s.log.Warn("remove stale snapshot temp", "path", path, "err", err)
9999 }
100100 continue
101101 }
@@ -104,7 +104,7 @@ func (s *Store) Load() ([]ports.SnapshotBlob, error) {
104104 }
105105 data, err := readSnapshotFileBounded(path)
106106 if err != nil {
107- s.log.Warn("read snapshot", "path", path, "error", err)
107+ s.log.Warn("read snapshot", "path", path, "err", err)
108108 continue
109109 }
110110 name := strings.TrimSuffix(base, ".snap")
@@ -95,7 +95,7 @@ func (s *Store) Load() ([]ports.SnapshotBlob, error) {
95 path := filepath.Join(s.dir, base)95 path := filepath.Join(s.dir, base)
96 if strings.HasPrefix(base, ".tmp-") {96 if strings.HasPrefix(base, ".tmp-") {
97 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {97 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
98- s.log.Warn("remove stale snapshot temp", "path", path, "error", err)98+ s.log.Warn("remove stale snapshot temp", "path", path, "err", err)
99 }99 }
100 continue100 continue
101 }101 }
@@ -104,7 +104,7 @@ func (s *Store) Load() ([]ports.SnapshotBlob, error) {
104 }104 }
105 data, err := readSnapshotFileBounded(path)105 data, err := readSnapshotFileBounded(path)
106 if err != nil {106 if err != nil {
107- s.log.Warn("read snapshot", "path", path, "error", err)107+ s.log.Warn("read snapshot", "path", path, "err", err)
108 continue108 continue
109 }109 }
110 name := strings.TrimSuffix(base, ".snap")110 name := strings.TrimSuffix(base, ".snap")
M internal/usecase/daemon/daemon.go +5 -1
@@ -547,7 +547,11 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
547547 sz = defaultSize
548548 }
549549 if d.snapsEnabled && (h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach || h.Intent == ports.IntentNew) {
550- <-d.restoreDone
550+ select {
551+ case <-d.restoreDone:
552+ case <-d.serveCtx.Done():
553+ return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
554+ }
551555 }
552556
553557 d.mu.Lock()
@@ -547,7 +547,11 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
547 sz = defaultSize547 sz = defaultSize
548 }548 }
549 if d.snapsEnabled && (h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach || h.Intent == ports.IntentNew) {549 if d.snapsEnabled && (h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach || h.Intent == ports.IntentNew) {
550- <-d.restoreDone550+ select {
551+ case <-d.restoreDone:
552+ case <-d.serveCtx.Done():
553+ return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
554+ }
551 }555 }
552 556
553 d.mu.Lock()557 d.mu.Lock()
M internal/usecase/daemon/session.go +6 -2
@@ -59,6 +59,7 @@ type session struct {
5959 createdAt int64
6060 mruAt atomic.Uint64
6161 snapDirty atomic.Bool
62+ snapEligible atomic.Bool
6263 // clipFiles records clipboard-image-transfer temp file paths (see
6364 // clipboard.go) written for this session, removed best-effort in
6465 // killSession.
@@ -153,6 +154,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
153154 if lastUsedSeq > 0 {
154155 sess.mruAt.Store(lastUsedSeq)
155156 }
157+ sess.snapEligible.Store(!ephemeral && name != "")
156158 if !ephemeral {
157159 if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {
158160 _ = pty.Close()
@@ -433,6 +435,7 @@ func (d *Daemon) renameSession(sess *session, name string) error {
433435 delete(d.stopped, name)
434436 sess.name = name
435437 sess.ephemeral = false
438+ sess.snapEligible.Store(name != "")
436439 sess.mu.Unlock()
437440 markSnapshotDirty(sess)
438441 return nil
@@ -495,14 +498,15 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
495498 if !purge && !isEphemeral && d.persistEnabled {
496499 d.refreshSessionCwd(sess)
497500 }
501+ var snapshotDeleteErr error
498502 if d.snapsEnabled && !isEphemeral {
499503 if purge {
500504 sess.mu.Lock()
501505 name := sess.name
502506 sess.mu.Unlock()
503507 if err := d.snaps.Delete(name); err != nil {
508+ snapshotDeleteErr = err
504509 d.log.Warn("deleting session snapshot failed", "err", err, "session", name)
505- return err
506510 }
507511 } else {
508512 d.captureSession(sess)
@@ -535,7 +539,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
535539 d.mu.Unlock()
536540 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
537541
538- var purgeErr error
542+ purgeErr := snapshotDeleteErr
539543 if !ephemeral && purge {
540544 if err := d.persist.Delete(stoppedName); err != nil {
541545 purgeErr = err
@@ -59,6 +59,7 @@ type session struct {
59 createdAt int6459 createdAt int64
60 mruAt atomic.Uint6460 mruAt atomic.Uint64
61 snapDirty atomic.Bool61 snapDirty atomic.Bool
62+ snapEligible atomic.Bool
62 // clipFiles records clipboard-image-transfer temp file paths (see63 // clipFiles records clipboard-image-transfer temp file paths (see
63 // clipboard.go) written for this session, removed best-effort in64 // clipboard.go) written for this session, removed best-effort in
64 // killSession.65 // killSession.
@@ -153,6 +154,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
153 if lastUsedSeq > 0 {154 if lastUsedSeq > 0 {
154 sess.mruAt.Store(lastUsedSeq)155 sess.mruAt.Store(lastUsedSeq)
155 }156 }
157+ sess.snapEligible.Store(!ephemeral && name != "")
156 if !ephemeral {158 if !ephemeral {
157 if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {159 if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {
158 _ = pty.Close()160 _ = pty.Close()
@@ -433,6 +435,7 @@ func (d *Daemon) renameSession(sess *session, name string) error {
433 delete(d.stopped, name)435 delete(d.stopped, name)
434 sess.name = name436 sess.name = name
435 sess.ephemeral = false437 sess.ephemeral = false
438+ sess.snapEligible.Store(name != "")
436 sess.mu.Unlock()439 sess.mu.Unlock()
437 markSnapshotDirty(sess)440 markSnapshotDirty(sess)
438 return nil441 return nil
@@ -495,14 +498,15 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
495 if !purge && !isEphemeral && d.persistEnabled {498 if !purge && !isEphemeral && d.persistEnabled {
496 d.refreshSessionCwd(sess)499 d.refreshSessionCwd(sess)
497 }500 }
501+ var snapshotDeleteErr error
498 if d.snapsEnabled && !isEphemeral {502 if d.snapsEnabled && !isEphemeral {
499 if purge {503 if purge {
500 sess.mu.Lock()504 sess.mu.Lock()
501 name := sess.name505 name := sess.name
502 sess.mu.Unlock()506 sess.mu.Unlock()
503 if err := d.snaps.Delete(name); err != nil {507 if err := d.snaps.Delete(name); err != nil {
508+ snapshotDeleteErr = err
504 d.log.Warn("deleting session snapshot failed", "err", err, "session", name)509 d.log.Warn("deleting session snapshot failed", "err", err, "session", name)
505- return err
506 }510 }
507 } else {511 } else {
508 d.captureSession(sess)512 d.captureSession(sess)
@@ -535,7 +539,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
535 d.mu.Unlock()539 d.mu.Unlock()
536 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)540 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
537 541
538- var purgeErr error542+ purgeErr := snapshotDeleteErr
539 if !ephemeral && purge {543 if !ephemeral && purge {
540 if err := d.persist.Delete(stoppedName); err != nil {544 if err := d.persist.Delete(stoppedName); err != nil {
541 purgeErr = err545 purgeErr = err
M internal/usecase/daemon/snapshot.go +18 -8
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55 "fmt"
6+ "math"
67 "time"
78
89 "github.com/bnema/vev/internal/domain"
@@ -48,9 +49,15 @@ func (d *Daemon) restoreSnapshots(ctx context.Context) {
4849 }
4950
5051 func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error {
52+ if err := ctx.Err(); err != nil {
53+ return err
54+ }
5155 if snap.Name == "" {
5256 return fmt.Errorf("snapshot: empty session name")
5357 }
58+ if snap.CreatedAt > math.MaxInt64 {
59+ return fmt.Errorf("snapshot: created_at overflows int64")
60+ }
5461 d.mu.Lock()
5562 if d.closing || d.findByNameLocked(snap.Name) != nil {
5663 d.mu.Unlock()
@@ -67,6 +74,10 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
6774 cancel()
6875 }
6976 for _, tabSnap := range snap.Tabs {
77+ if err := ctx.Err(); err != nil {
78+ closeOpened()
79+ return err
80+ }
7081 tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)}
7182 if !tbSize.Valid() {
7283 closeOpened()
@@ -88,6 +99,10 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
8899 tb.ctx, tb.cancel = context.WithCancel(sctx)
89100 opened = append(opened, tb)
90101 for _, paneSnap := range tabSnap.Panes {
102+ if err := ctx.Err(); err != nil {
103+ closeOpened()
104+ return err
105+ }
91106 contentRect, ok := placementByPane[paneSnap.ID]
92107 if !ok {
93108 closeOpened()
@@ -115,6 +130,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
115130 if sess.active < 0 || sess.active >= len(sess.tabs) {
116131 sess.active = 0
117132 }
133+ sess.snapEligible.Store(true)
118134 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
119135 sess.cwd = snap.Tabs[0].Panes[0].Cwd
120136 }
@@ -151,10 +167,7 @@ func markSnapshotDirty(sess *session) {
151167 if sess == nil {
152168 return
153169 }
154- sess.mu.Lock()
155- named := !sess.ephemeral && sess.name != ""
156- sess.mu.Unlock()
157- if named {
170+ if sess.snapEligible.Load() {
158171 sess.snapDirty.Store(true)
159172 }
160173 }
@@ -292,10 +305,7 @@ func (d *Daemon) snapshotSaver(ctx context.Context) {
292305 sessions := d.sessionsSnapshotLocked()
293306 d.mu.Unlock()
294307 for _, sess := range sessions {
295- sess.mu.Lock()
296- named := !sess.ephemeral && sess.name != ""
297- sess.mu.Unlock()
298- if named && sess.snapDirty.Swap(false) {
308+ if sess.snapEligible.Load() && sess.snapDirty.Swap(false) {
299309 if !d.captureSession(sess) {
300310 sess.snapDirty.Store(true)
301311 }
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "math"
6 "time"7 "time"
7 8
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
@@ -48,9 +49,15 @@ func (d *Daemon) restoreSnapshots(ctx context.Context) {
48 }49 }
49 50
50 func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error {51 func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) error {
52+ if err := ctx.Err(); err != nil {
53+ return err
54+ }
51 if snap.Name == "" {55 if snap.Name == "" {
52 return fmt.Errorf("snapshot: empty session name")56 return fmt.Errorf("snapshot: empty session name")
53 }57 }
58+ if snap.CreatedAt > math.MaxInt64 {
59+ return fmt.Errorf("snapshot: created_at overflows int64")
60+ }
54 d.mu.Lock()61 d.mu.Lock()
55 if d.closing || d.findByNameLocked(snap.Name) != nil {62 if d.closing || d.findByNameLocked(snap.Name) != nil {
56 d.mu.Unlock()63 d.mu.Unlock()
@@ -67,6 +74,10 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
67 cancel()74 cancel()
68 }75 }
69 for _, tabSnap := range snap.Tabs {76 for _, tabSnap := range snap.Tabs {
77+ if err := ctx.Err(); err != nil {
78+ closeOpened()
79+ return err
80+ }
70 tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)}81 tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)}
71 if !tbSize.Valid() {82 if !tbSize.Valid() {
72 closeOpened()83 closeOpened()
@@ -88,6 +99,10 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
88 tb.ctx, tb.cancel = context.WithCancel(sctx)99 tb.ctx, tb.cancel = context.WithCancel(sctx)
89 opened = append(opened, tb)100 opened = append(opened, tb)
90 for _, paneSnap := range tabSnap.Panes {101 for _, paneSnap := range tabSnap.Panes {
102+ if err := ctx.Err(); err != nil {
103+ closeOpened()
104+ return err
105+ }
91 contentRect, ok := placementByPane[paneSnap.ID]106 contentRect, ok := placementByPane[paneSnap.ID]
92 if !ok {107 if !ok {
93 closeOpened()108 closeOpened()
@@ -115,6 +130,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
115 if sess.active < 0 || sess.active >= len(sess.tabs) {130 if sess.active < 0 || sess.active >= len(sess.tabs) {
116 sess.active = 0131 sess.active = 0
117 }132 }
133+ sess.snapEligible.Store(true)
118 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {134 if len(snap.Tabs) > 0 && len(snap.Tabs[0].Panes) > 0 {
119 sess.cwd = snap.Tabs[0].Panes[0].Cwd135 sess.cwd = snap.Tabs[0].Panes[0].Cwd
120 }136 }
@@ -151,10 +167,7 @@ func markSnapshotDirty(sess *session) {
151 if sess == nil {167 if sess == nil {
152 return168 return
153 }169 }
154- sess.mu.Lock()170+ if sess.snapEligible.Load() {
155- named := !sess.ephemeral && sess.name != ""
156- sess.mu.Unlock()
157- if named {
158 sess.snapDirty.Store(true)171 sess.snapDirty.Store(true)
159 }172 }
160 }173 }
@@ -292,10 +305,7 @@ func (d *Daemon) snapshotSaver(ctx context.Context) {
292 sessions := d.sessionsSnapshotLocked()305 sessions := d.sessionsSnapshotLocked()
293 d.mu.Unlock()306 d.mu.Unlock()
294 for _, sess := range sessions {307 for _, sess := range sessions {
295- sess.mu.Lock()308+ if sess.snapEligible.Load() && sess.snapDirty.Swap(false) {
296- named := !sess.ephemeral && sess.name != ""
297- sess.mu.Unlock()
298- if named && sess.snapDirty.Swap(false) {
299 if !d.captureSession(sess) {309 if !d.captureSession(sess) {
300 sess.snapDirty.Store(true)310 sess.snapDirty.Store(true)
301 }311 }
M internal/usecase/daemon/snapshot_restore_test.go +43 -0
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55 "io"
6+ "math"
67 "sync"
78 "testing"
89 "time"
@@ -142,6 +143,33 @@ func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
142143 }
143144 }
144145
146+func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) {
147+ store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "overflow", Data: mustSnapshotBytes(t, snapcodec.Session{
148+ Name: "overflow",
149+ CreatedAt: uint64(math.MaxInt64) + 1,
150+ Tabs: []snapcodec.Tab{{
151+ Cols: 80,
152+ Rows: 24,
153+ Tree: layout.NewTree("pane-1"),
154+ Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/bad"}},
155+ }},
156+ })}}}
157+ factory := &restorePTYFactory{}
158+ d := newTestDaemon(t, factory, stubClock{})
159+ WithSnapshotStore(store)(d)
160+
161+ d.restoreSnapshots(context.Background())
162+
163+ records, err := d.persist.LoadAll()
164+ require.NoError(t, err)
165+ require.Empty(t, records)
166+ require.Len(t, factory.opens, 0)
167+ d.mu.Lock()
168+ restored := d.findByNameLocked("overflow")
169+ d.mu.Unlock()
170+ require.Nil(t, restored)
171+}
172+
145173 func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
146174 releaseLoad := make(chan struct{})
147175 loaded := make(chan struct{})
@@ -183,6 +211,21 @@ func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
183211 }
184212 }
185213
214+func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) {
215+ d := newTestDaemon(t, &restorePTYFactory{}, stubClock{})
216+ WithSnapshotStore(&restoreSnapshotStore{})(d)
217+ d.serveCancel()
218+
219+ tr, _ := newCapturingTransport(t)
220+ sess, ac, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
221+
222+ require.Nil(t, sess)
223+ require.Nil(t, ac)
224+ var perr *protoErr
225+ require.ErrorAs(t, err, &perr)
226+ require.Equal(t, ports.ErrServerShutdown, perr.code)
227+}
228+
186229 func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {
187230 t.Helper()
188231 b, err := snapcodec.Marshal(s)
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "io"5 "io"
6+ "math"
6 "sync"7 "sync"
7 "testing"8 "testing"
8 "time"9 "time"
@@ -142,6 +143,33 @@ func TestRestoreSnapshotsSkipsLiveCorruptAndEmpty(t *testing.T) {
142 }143 }
143 }144 }
144 145
146+func TestRestoreSnapshotsSkipsCreatedAtOverflow(t *testing.T) {
147+ store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "overflow", Data: mustSnapshotBytes(t, snapcodec.Session{
148+ Name: "overflow",
149+ CreatedAt: uint64(math.MaxInt64) + 1,
150+ Tabs: []snapcodec.Tab{{
151+ Cols: 80,
152+ Rows: 24,
153+ Tree: layout.NewTree("pane-1"),
154+ Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/bad"}},
155+ }},
156+ })}}}
157+ factory := &restorePTYFactory{}
158+ d := newTestDaemon(t, factory, stubClock{})
159+ WithSnapshotStore(store)(d)
160+
161+ d.restoreSnapshots(context.Background())
162+
163+ records, err := d.persist.LoadAll()
164+ require.NoError(t, err)
165+ require.Empty(t, records)
166+ require.Len(t, factory.opens, 0)
167+ d.mu.Lock()
168+ restored := d.findByNameLocked("overflow")
169+ d.mu.Unlock()
170+ require.Nil(t, restored)
171+}
172+
145 func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {173 func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
146 releaseLoad := make(chan struct{})174 releaseLoad := make(chan struct{})
147 loaded := make(chan struct{})175 loaded := make(chan struct{})
@@ -183,6 +211,21 @@ func TestNamedRouteWaitsForRestoreBarrier(t *testing.T) {
183 }211 }
184 }212 }
185 213
214+func TestNamedRouteRestoreBarrierReturnsShutdown(t *testing.T) {
215+ d := newTestDaemon(t, &restorePTYFactory{}, stubClock{})
216+ WithSnapshotStore(&restoreSnapshotStore{})(d)
217+ d.serveCancel()
218+
219+ tr, _ := newCapturingTransport(t)
220+ sess, ac, err := d.route(ports.Hello{Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}}, tr)
221+
222+ require.Nil(t, sess)
223+ require.Nil(t, ac)
224+ var perr *protoErr
225+ require.ErrorAs(t, err, &perr)
226+ require.Equal(t, ports.ErrServerShutdown, perr.code)
227+}
228+
186 func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {229 func mustSnapshotBytes(t *testing.T, s snapcodec.Session) []byte {
187 t.Helper()230 t.Helper()
188 b, err := snapcodec.Marshal(s)231 b, err := snapcodec.Marshal(s)
M internal/usecase/daemon/snapshot_test.go +10 -2
@@ -110,6 +110,7 @@ func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) {
110110 defer cancel()
111111 tb := newTestTabWithContext(p, sctx, cancel)
112112 sess := &session{id: "sync", name: "sync", tabs: []*tab{tb}, ctx: sctx, cancel: cancel}
113+ sess.snapEligible.Store(true)
113114 d.sessions[sess.id] = sess
114115 d.sessWg.Add(1)
115116 d.ptyReader(sess, tb, tb.focusedPane())
@@ -173,9 +174,14 @@ func TestKillSessionPurgeReturnsSnapshotDeleteError(t *testing.T) {
173174 d.sessions[sess.id] = sess
174175
175176 store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once()
177+ mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
178+ require.True(t, ok)
179+ mockPTY.EXPECT().Close().Return(nil).Once()
180+
176181 err := d.killSession(sess, 0, true)
177182 require.ErrorContains(t, err, "delete failed")
178- require.Contains(t, d.sessions, sess.id)
183+ require.NotContains(t, d.sessions, sess.id)
184+ require.NotContains(t, d.stopped, "work")
179185 }
180186
181187 func TestRefreshSessionCwdMarksSnapshotDirty(t *testing.T) {
@@ -300,7 +306,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
300306 p := tb.panes["pane-1"]
301307 p.screen.Write([]byte("hello"))
302308 p.scrollback.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
303- return &session{
309+ sess := &session{
304310 id: domain.SessionID("sess-" + name),
305311 name: name,
306312 ephemeral: ephemeral,
@@ -311,6 +317,8 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
311317 cwd: cwd,
312318 createdAt: 42,
313319 }
320+ sess.snapEligible.Store(!ephemeral && name != "")
321+ return sess
314322 }
315323
316324 type manualSnapshotClock struct {
@@ -110,6 +110,7 @@ func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) {
110 defer cancel()110 defer cancel()
111 tb := newTestTabWithContext(p, sctx, cancel)111 tb := newTestTabWithContext(p, sctx, cancel)
112 sess := &session{id: "sync", name: "sync", tabs: []*tab{tb}, ctx: sctx, cancel: cancel}112 sess := &session{id: "sync", name: "sync", tabs: []*tab{tb}, ctx: sctx, cancel: cancel}
113+ sess.snapEligible.Store(true)
113 d.sessions[sess.id] = sess114 d.sessions[sess.id] = sess
114 d.sessWg.Add(1)115 d.sessWg.Add(1)
115 d.ptyReader(sess, tb, tb.focusedPane())116 d.ptyReader(sess, tb, tb.focusedPane())
@@ -173,9 +174,14 @@ func TestKillSessionPurgeReturnsSnapshotDeleteError(t *testing.T) {
173 d.sessions[sess.id] = sess174 d.sessions[sess.id] = sess
174 175
175 store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once()176 store.EXPECT().Delete("work").Return(errors.New("delete failed")).Once()
177+ mockPTY, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
178+ require.True(t, ok)
179+ mockPTY.EXPECT().Close().Return(nil).Once()
180+
176 err := d.killSession(sess, 0, true)181 err := d.killSession(sess, 0, true)
177 require.ErrorContains(t, err, "delete failed")182 require.ErrorContains(t, err, "delete failed")
178- require.Contains(t, d.sessions, sess.id)183+ require.NotContains(t, d.sessions, sess.id)
184+ require.NotContains(t, d.stopped, "work")
179 }185 }
180 186
181 func TestRefreshSessionCwdMarksSnapshotDirty(t *testing.T) {187 func TestRefreshSessionCwdMarksSnapshotDirty(t *testing.T) {
@@ -300,7 +306,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
300 p := tb.panes["pane-1"]306 p := tb.panes["pane-1"]
301 p.screen.Write([]byte("hello"))307 p.screen.Write([]byte("hello"))
302 p.scrollback.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})308 p.scrollback.Append([]renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
303- return &session{309+ sess := &session{
304 id: domain.SessionID("sess-" + name),310 id: domain.SessionID("sess-" + name),
305 name: name,311 name: name,
306 ephemeral: ephemeral,312 ephemeral: ephemeral,
@@ -311,6 +317,8 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
311 cwd: cwd,317 cwd: cwd,
312 createdAt: 42,318 createdAt: 42,
313 }319 }
320+ sess.snapEligible.Store(!ephemeral && name != "")
321+ return sess
314 }322 }
315 323
316 type manualSnapshotClock struct {324 type manualSnapshotClock struct {
M internal/usecase/snapshot/codec.go +9 -6
@@ -273,10 +273,11 @@ func writeTab(w *payloadWriter, t Tab) error {
273273 if err := w.putString(string(t.Focus)); err != nil {
274274 return err
275275 }
276- if t.Tree == nil {
277- t.Tree = &layout.Tree{Focus: t.Focus}
276+ var root *layout.Node
277+ if t.Tree != nil {
278+ root = t.Tree.Root
278279 }
279- if err := writeNode(w, t.Tree.Root); err != nil {
280+ if err := writeNode(w, root); err != nil {
280281 return err
281282 }
282283 if len(t.Panes) > math.MaxUint16 {
@@ -329,8 +330,8 @@ func readTab(r *payloadReader) (Tab, error) {
329330
330331 func writeNode(w *payloadWriter, n *layout.Node) error {
331332 if n == nil {
332- w.putUint8(0)
333- return w.putString("")
333+ w.putUint8(3)
334+ return nil
334335 }
335336 switch n.Kind {
336337 case layout.Leaf:
@@ -396,6 +397,8 @@ func readNode(r *payloadReader) (*layout.Node, error) {
396397 return nil, err
397398 }
398399 return &layout.Node{Kind: layout.Stack, Expanded: layout.PaneID(exp), Children: children}, nil
400+ case 3:
401+ return nil, nil
399402 default:
400403 return nil, fmt.Errorf("%w: unknown node kind", ErrInvalidData)
401404 }
@@ -713,7 +716,7 @@ func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error {
713716
714717 func validateNode(n *layout.Node, ids map[layout.PaneID]struct{}) error {
715718 if n == nil {
716- return nil
719+ return fmt.Errorf("%w: nil child node", ErrInvalidData)
717720 }
718721 switch n.Kind {
719722 case layout.Leaf:
@@ -273,10 +273,11 @@ func writeTab(w *payloadWriter, t Tab) error {
273 if err := w.putString(string(t.Focus)); err != nil {273 if err := w.putString(string(t.Focus)); err != nil {
274 return err274 return err
275 }275 }
276- if t.Tree == nil {276+ var root *layout.Node
277- t.Tree = &layout.Tree{Focus: t.Focus}277+ if t.Tree != nil {
278+ root = t.Tree.Root
278 }279 }
279- if err := writeNode(w, t.Tree.Root); err != nil {280+ if err := writeNode(w, root); err != nil {
280 return err281 return err
281 }282 }
282 if len(t.Panes) > math.MaxUint16 {283 if len(t.Panes) > math.MaxUint16 {
@@ -329,8 +330,8 @@ func readTab(r *payloadReader) (Tab, error) {
329 330
330 func writeNode(w *payloadWriter, n *layout.Node) error {331 func writeNode(w *payloadWriter, n *layout.Node) error {
331 if n == nil {332 if n == nil {
332- w.putUint8(0)333+ w.putUint8(3)
333- return w.putString("")334+ return nil
334 }335 }
335 switch n.Kind {336 switch n.Kind {
336 case layout.Leaf:337 case layout.Leaf:
@@ -396,6 +397,8 @@ func readNode(r *payloadReader) (*layout.Node, error) {
396 return nil, err397 return nil, err
397 }398 }
398 return &layout.Node{Kind: layout.Stack, Expanded: layout.PaneID(exp), Children: children}, nil399 return &layout.Node{Kind: layout.Stack, Expanded: layout.PaneID(exp), Children: children}, nil
400+ case 3:
401+ return nil, nil
399 default:402 default:
400 return nil, fmt.Errorf("%w: unknown node kind", ErrInvalidData)403 return nil, fmt.Errorf("%w: unknown node kind", ErrInvalidData)
401 }404 }
@@ -713,7 +716,7 @@ func validateTree(t *layout.Tree, ids map[layout.PaneID]struct{}) error {
713 716
714 func validateNode(n *layout.Node, ids map[layout.PaneID]struct{}) error {717 func validateNode(n *layout.Node, ids map[layout.PaneID]struct{}) error {
715 if n == nil {718 if n == nil {
716- return nil719+ return fmt.Errorf("%w: nil child node", ErrInvalidData)
717 }720 }
718 switch n.Kind {721 switch n.Kind {
719 case layout.Leaf:722 case layout.Leaf:
M internal/usecase/snapshot/codec_test.go +39 -1
@@ -26,6 +26,37 @@ func TestMarshalMinimalGolden(t *testing.T) {
2626 }
2727 }
2828
29+func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
30+ cases := []struct {
31+ name string
32+ tab Tab
33+ }{
34+ {name: "nil tree", tab: Tab{Tree: nil}},
35+ {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}},
36+ }
37+ for _, tc := range cases {
38+ t.Run(tc.name, func(t *testing.T) {
39+ b, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})
40+ if err != nil {
41+ t.Fatalf("Marshal() error = %v", err)
42+ }
43+ got, err := Unmarshal(b)
44+ if err != nil {
45+ t.Fatalf("Unmarshal() error = %v", err)
46+ }
47+ if len(got.Tabs) != 1 {
48+ t.Fatalf("tabs len = %d, want 1", len(got.Tabs))
49+ }
50+ if got.Tabs[0].Tree == nil {
51+ t.Fatalf("Tree = nil, want tree with nil root")
52+ }
53+ if got.Tabs[0].Tree.Root != nil {
54+ t.Fatalf("Root = %#v, want nil", got.Tabs[0].Tree.Root)
55+ }
56+ })
57+ }
58+}
59+
2960 func TestRoundTripSessions(t *testing.T) {
3061 boldRGB := renderer.DefaultStyle()
3162 boldRGB.Bold = true
@@ -217,7 +248,7 @@ func equalSession(a, b Session) bool {
217248 }
218249
219250 func equalTab(a, b Tab) bool {
220- if a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(a.Tree.Root, b.Tree.Root) {
251+ if a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) {
221252 return false
222253 }
223254 for i := range a.Panes {
@@ -228,6 +259,13 @@ func equalTab(a, b Tab) bool {
228259 return true
229260 }
230261
262+func treeRoot(t *layout.Tree) *layout.Node {
263+ if t == nil {
264+ return nil
265+ }
266+ return t.Root
267+}
268+
231269 func equalNode(a, b *layout.Node) bool {
232270 if a == nil || b == nil {
233271 return a == b
@@ -26,6 +26,37 @@ func TestMarshalMinimalGolden(t *testing.T) {
26 }26 }
27 }27 }
28 28
29+func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
30+ cases := []struct {
31+ name string
32+ tab Tab
33+ }{
34+ {name: "nil tree", tab: Tab{Tree: nil}},
35+ {name: "nil root", tab: Tab{Tree: &layout.Tree{Root: nil}}},
36+ }
37+ for _, tc := range cases {
38+ t.Run(tc.name, func(t *testing.T) {
39+ b, err := Marshal(Session{Name: "s", Tabs: []Tab{tc.tab}})
40+ if err != nil {
41+ t.Fatalf("Marshal() error = %v", err)
42+ }
43+ got, err := Unmarshal(b)
44+ if err != nil {
45+ t.Fatalf("Unmarshal() error = %v", err)
46+ }
47+ if len(got.Tabs) != 1 {
48+ t.Fatalf("tabs len = %d, want 1", len(got.Tabs))
49+ }
50+ if got.Tabs[0].Tree == nil {
51+ t.Fatalf("Tree = nil, want tree with nil root")
52+ }
53+ if got.Tabs[0].Tree.Root != nil {
54+ t.Fatalf("Root = %#v, want nil", got.Tabs[0].Tree.Root)
55+ }
56+ })
57+ }
58+}
59+
29 func TestRoundTripSessions(t *testing.T) {60 func TestRoundTripSessions(t *testing.T) {
30 boldRGB := renderer.DefaultStyle()61 boldRGB := renderer.DefaultStyle()
31 boldRGB.Bold = true62 boldRGB.Bold = true
@@ -217,7 +248,7 @@ func equalSession(a, b Session) bool {
217 }248 }
218 249
219 func equalTab(a, b Tab) bool {250 func equalTab(a, b Tab) bool {
220- if a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(a.Tree.Root, b.Tree.Root) {251+ if a.Cols != b.Cols || a.Rows != b.Rows || a.NextPaneID != b.NextPaneID || a.Focus != b.Focus || len(a.Panes) != len(b.Panes) || !equalNode(treeRoot(a.Tree), treeRoot(b.Tree)) {
221 return false252 return false
222 }253 }
223 for i := range a.Panes {254 for i := range a.Panes {
@@ -228,6 +259,13 @@ func equalTab(a, b Tab) bool {
228 return true259 return true
229 }260 }
230 261
262+func treeRoot(t *layout.Tree) *layout.Node {
263+ if t == nil {
264+ return nil
265+ }
266+ return t.Root
267+}
268+
231 func equalNode(a, b *layout.Node) bool {269 func equalNode(a, b *layout.Node) bool {
232 if a == nil || b == nil {270 if a == nil || b == nil {
233 return a == b271 return a == b

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