detached be1bb36

fix(daemon): serialize final session snapshots

commit
be1bb364c4f2c113da90640bb6abed0a4bc08879
author
brice · · 2026-07-24 16:11 UTC
committer
brice · · 2026-07-24 16:14 UTC
parent
253fc6a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +97 -1
M internal/usecase/daemon/session.go +28 -1
@@ -40,7 +40,11 @@ type session struct {
4040 // and vev-cmd control) for this session, so a one-shot command cannot
4141 // resolve focus against one state and mutate a later one. Lock order:
4242 // dispatchMu strictly before d.mu, sess.mu, or any tab mu.
43- dispatchMu sync.Mutex
43+ dispatchMu sync.Mutex
44+ // teardownMu is the outermost per-session lifecycle lock. It serializes
45+ // competing pane-exit, control, and daemon-shutdown teardown paths before
46+ // they acquire daemon, session, snapshot, or pane locks.
47+ teardownMu sync.Mutex
4448 mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state
4549 themeMu sync.Mutex
4650 tabs []*tab
@@ -908,6 +912,29 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
908912 // only immutable capture state; it never observes closed worker channels or
909913 // session-owned cache that teardown has released.
910914 func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline) error {
915+ if sess == nil {
916+ return nil
917+ }
918+ // Pane EOF, explicit close, and daemon shutdown can all converge on the same
919+ // session. Only one owner may create the terminal mutation and checkpoint;
920+ // later callers observe the completed registry transition instead of
921+ // manufacturing a newer generation that the owner will quarantine.
922+ sess.teardownMu.Lock()
923+ defer sess.teardownMu.Unlock()
924+
925+ d.mu.Lock()
926+ current := d.sessions[sess.id]
927+ d.mu.Unlock()
928+ if current != sess {
929+ if purge {
930+ sess.mu.Lock()
931+ name := sess.name
932+ sess.mu.Unlock()
933+ return d.retryStoppedPurge(name)
934+ }
935+ return nil
936+ }
937+
911938 ringing := sess.anyAttention()
912939 sess.mu.Lock()
913940 isEphemeral := sess.ephemeral
@@ -40,7 +40,11 @@ type session struct {
40 // and vev-cmd control) for this session, so a one-shot command cannot40 // and vev-cmd control) for this session, so a one-shot command cannot
41 // resolve focus against one state and mutate a later one. Lock order:41 // resolve focus against one state and mutate a later one. Lock order:
42 // dispatchMu strictly before d.mu, sess.mu, or any tab mu.42 // dispatchMu strictly before d.mu, sess.mu, or any tab mu.
43- dispatchMu sync.Mutex43+ dispatchMu sync.Mutex
44+ // teardownMu is the outermost per-session lifecycle lock. It serializes
45+ // competing pane-exit, control, and daemon-shutdown teardown paths before
46+ // they acquire daemon, session, snapshot, or pane locks.
47+ teardownMu sync.Mutex
44 mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state48 mu sync.Mutex // guards tabs, active, client, clipFiles, and clipboard queue state
45 themeMu sync.Mutex49 themeMu sync.Mutex
46 tabs []*tab50 tabs []*tab
@@ -908,6 +912,29 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
908 // only immutable capture state; it never observes closed worker channels or912 // only immutable capture state; it never observes closed worker channels or
909 // session-owned cache that teardown has released.913 // session-owned cache that teardown has released.
910 func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline) error {914 func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, purge bool, deadline *snapshotShutdownDeadline) error {
915+ if sess == nil {
916+ return nil
917+ }
918+ // Pane EOF, explicit close, and daemon shutdown can all converge on the same
919+ // session. Only one owner may create the terminal mutation and checkpoint;
920+ // later callers observe the completed registry transition instead of
921+ // manufacturing a newer generation that the owner will quarantine.
922+ sess.teardownMu.Lock()
923+ defer sess.teardownMu.Unlock()
924+
925+ d.mu.Lock()
926+ current := d.sessions[sess.id]
927+ d.mu.Unlock()
928+ if current != sess {
929+ if purge {
930+ sess.mu.Lock()
931+ name := sess.name
932+ sess.mu.Unlock()
933+ return d.retryStoppedPurge(name)
934+ }
935+ return nil
936+ }
937+
911 ringing := sess.anyAttention()938 ringing := sess.anyAttention()
912 sess.mu.Lock()939 sess.mu.Lock()
913 isEphemeral := sess.ephemeral940 isEphemeral := sess.ephemeral
M internal/usecase/daemon/snapshot_scheduler_test.go +69 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "io"
7+ "runtime"
78 "sync"
89 "sync/atomic"
910 "testing"
@@ -442,6 +443,74 @@ func TestForcedSnapshotShutdownTimeoutRetainsRetryableStateAndNotice(t *testing.
442443 }
443444 }
444445
446+func TestConcurrentSessionTeardownPublishesOneFinalSnapshot(t *testing.T) {
447+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
448+ repository := portsmocks.NewMockSnapshotRepository(t)
449+ WithSnapshotRepository(repository, nil)(d)
450+ startSnapshotEncodeWorker(t, d)
451+
452+ publicationStarted := make(chan struct{})
453+ releasePublication := make(chan struct{})
454+ var releaseOnce sync.Once
455+ t.Cleanup(func() { releaseOnce.Do(func() { close(releasePublication) }) })
456+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ ports.SnapshotPublication) error {
457+ close(publicationStarted)
458+ <-releasePublication
459+ return nil
460+ }).Once()
461+
462+ sess := newSnapshotTestSession(t, "work", false, "/work")
463+ pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
464+ require.True(t, ok)
465+ pty.EXPECT().Close().Return(nil).Maybe()
466+ d.sessions[sess.id] = sess
467+
468+ first := make(chan error, 1)
469+ go func() { first <- d.killSession(sess, ports.ReasonServerShutdown, false) }()
470+ select {
471+ case <-publicationStarted:
472+ case <-time.After(testWaitTimeout):
473+ t.Fatal("first terminal snapshot did not reach repository")
474+ }
475+
476+ const duplicateCallers = 8
477+ started := make(chan struct{}, duplicateCallers)
478+ duplicates := make(chan error, duplicateCallers)
479+ for range duplicateCallers {
480+ go func() {
481+ started <- struct{}{}
482+ duplicates <- d.killSession(sess, ports.ReasonServerShutdown, false)
483+ }()
484+ }
485+ for range duplicateCallers {
486+ <-started
487+ }
488+ for range 1000 {
489+ runtime.Gosched()
490+ sess.snapshotMu.Lock()
491+ generation := sess.snapshotGeneration
492+ sess.snapshotMu.Unlock()
493+ if generation > 1 {
494+ break
495+ }
496+ }
497+
498+ sess.snapshotMu.Lock()
499+ generation := sess.snapshotGeneration
500+ sess.snapshotMu.Unlock()
501+ require.Equal(t, uint64(1), generation, "duplicate teardown callers must not create synthetic terminal generations")
502+
503+ releaseOnce.Do(func() { close(releasePublication) })
504+ require.NoError(t, <-first)
505+ for range duplicateCallers {
506+ require.NoError(t, <-duplicates)
507+ }
508+ sess.snapshotMu.Lock()
509+ generation = sess.snapshotGeneration
510+ sess.snapshotMu.Unlock()
511+ require.Equal(t, uint64(1), generation, "completed duplicate teardowns must leave the terminal generation unchanged")
512+}
513+
445514 func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) {
446515 p, releasePTY := newBlockingPTY(t)
447516 defer releasePTY()
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "io"6 "io"
7+ "runtime"
7 "sync"8 "sync"
8 "sync/atomic"9 "sync/atomic"
9 "testing"10 "testing"
@@ -442,6 +443,74 @@ func TestForcedSnapshotShutdownTimeoutRetainsRetryableStateAndNotice(t *testing.
442 }443 }
443 }444 }
444 445
446+func TestConcurrentSessionTeardownPublishesOneFinalSnapshot(t *testing.T) {
447+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
448+ repository := portsmocks.NewMockSnapshotRepository(t)
449+ WithSnapshotRepository(repository, nil)(d)
450+ startSnapshotEncodeWorker(t, d)
451+
452+ publicationStarted := make(chan struct{})
453+ releasePublication := make(chan struct{})
454+ var releaseOnce sync.Once
455+ t.Cleanup(func() { releaseOnce.Do(func() { close(releasePublication) }) })
456+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ ports.SnapshotPublication) error {
457+ close(publicationStarted)
458+ <-releasePublication
459+ return nil
460+ }).Once()
461+
462+ sess := newSnapshotTestSession(t, "work", false, "/work")
463+ pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
464+ require.True(t, ok)
465+ pty.EXPECT().Close().Return(nil).Maybe()
466+ d.sessions[sess.id] = sess
467+
468+ first := make(chan error, 1)
469+ go func() { first <- d.killSession(sess, ports.ReasonServerShutdown, false) }()
470+ select {
471+ case <-publicationStarted:
472+ case <-time.After(testWaitTimeout):
473+ t.Fatal("first terminal snapshot did not reach repository")
474+ }
475+
476+ const duplicateCallers = 8
477+ started := make(chan struct{}, duplicateCallers)
478+ duplicates := make(chan error, duplicateCallers)
479+ for range duplicateCallers {
480+ go func() {
481+ started <- struct{}{}
482+ duplicates <- d.killSession(sess, ports.ReasonServerShutdown, false)
483+ }()
484+ }
485+ for range duplicateCallers {
486+ <-started
487+ }
488+ for range 1000 {
489+ runtime.Gosched()
490+ sess.snapshotMu.Lock()
491+ generation := sess.snapshotGeneration
492+ sess.snapshotMu.Unlock()
493+ if generation > 1 {
494+ break
495+ }
496+ }
497+
498+ sess.snapshotMu.Lock()
499+ generation := sess.snapshotGeneration
500+ sess.snapshotMu.Unlock()
501+ require.Equal(t, uint64(1), generation, "duplicate teardown callers must not create synthetic terminal generations")
502+
503+ releaseOnce.Do(func() { close(releasePublication) })
504+ require.NoError(t, <-first)
505+ for range duplicateCallers {
506+ require.NoError(t, <-duplicates)
507+ }
508+ sess.snapshotMu.Lock()
509+ generation = sess.snapshotGeneration
510+ sess.snapshotMu.Unlock()
511+ require.Equal(t, uint64(1), generation, "completed duplicate teardowns must leave the terminal generation unchanged")
512+}
513+
445 func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) {514 func TestServeShutdownCheckpointsBeforeStoppingSnapshotWorker(t *testing.T) {
446 p, releasePTY := newBlockingPTY(t)515 p, releasePTY := newBlockingPTY(t)
447 defer releasePTY()516 defer releasePTY()

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