detached d1419bc

fix(daemon): preserve forced checkpoint state

commit
d1419bcba9c84676b4adb9a5e2b4df5197813c38
author
brice · · 2026-07-21 22:40 UTC
committer
brice · · 2026-07-21 22:41 UTC
parent
e3b7703
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +289 -15
M internal/usecase/daemon/session.go +18 -10
@@ -56,18 +56,23 @@ type session struct {
5656 // snapshotMu serializes the dirty generation with worker completion. It is
5757 // intentionally independent from mu: persistence never holds session state
5858 // locks while encoding or writing.
59- snapshotMu sync.Mutex
60- snapshotGeneration uint64
61- snapshotCapturedGeneration uint64
62- snapshotNextEligibleAt time.Time
63- snapshotAttempted bool
64- snapshotAttemptKind snapshotAttemptKind
59+ snapshotMu sync.Mutex
60+ snapshotGeneration uint64
61+ snapshotCapturedGeneration uint64
62+ snapshotPublishedGeneration uint64
63+ snapshotNextEligibleAt time.Time
64+ snapshotAttempted bool
65+ snapshotAttemptKind snapshotAttemptKind
6566 // The coordinator state below is guarded by snapshotMu. A capture can be
6667 // queued globally or in flight, never more than one of each per session.
6768 // Quarantine cancels the session publication context before destructive
6869 // repository operations and publicationDone joins a started publication.
69- snapshotPending bool
70- snapshotPendingCaptures uint
70+ snapshotPending bool
71+ snapshotPendingCaptures uint
72+ // snapshotForcedGeneration is the newest dirty generation a forced
73+ // checkpoint must publish. It survives an older routine capture so worker
74+ // completion can enqueue exactly one forced successor.
75+ snapshotForcedGeneration uint64
7176 snapshotQueuedCapture *snapshotCapture
7277 snapshotInFlightCapture *snapshotCapture
7378 snapshotPublicationDone chan struct{}
@@ -809,11 +814,14 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
809814 }
810815 } else {
811816 markSnapshotDirty(sess)
812- if !d.scheduleFinalSnapshot(sess) {
817+ sess.snapshotMu.Lock()
818+ terminalGeneration := sess.snapshotGeneration
819+ sess.snapshotMu.Unlock()
820+ if !d.scheduleFinalSnapshot(sess) || !d.waitForSnapshotGeneration(sess, terminalGeneration) {
813821 sess.mu.Lock()
814822 name := sess.name
815823 sess.mu.Unlock()
816- terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable or saturated", name)
824+ terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable, saturated, or timed out", name)
817825 if reason != ports.ReasonServerShutdown {
818826 return terminalSnapshotErr
819827 }
@@ -56,18 +56,23 @@ type session struct {
56 // snapshotMu serializes the dirty generation with worker completion. It is56 // snapshotMu serializes the dirty generation with worker completion. It is
57 // intentionally independent from mu: persistence never holds session state57 // intentionally independent from mu: persistence never holds session state
58 // locks while encoding or writing.58 // locks while encoding or writing.
59- snapshotMu sync.Mutex59+ snapshotMu sync.Mutex
60- snapshotGeneration uint6460+ snapshotGeneration uint64
61- snapshotCapturedGeneration uint6461+ snapshotCapturedGeneration uint64
62- snapshotNextEligibleAt time.Time62+ snapshotPublishedGeneration uint64
63- snapshotAttempted bool63+ snapshotNextEligibleAt time.Time
64- snapshotAttemptKind snapshotAttemptKind64+ snapshotAttempted bool
65+ snapshotAttemptKind snapshotAttemptKind
65 // The coordinator state below is guarded by snapshotMu. A capture can be66 // The coordinator state below is guarded by snapshotMu. A capture can be
66 // queued globally or in flight, never more than one of each per session.67 // queued globally or in flight, never more than one of each per session.
67 // Quarantine cancels the session publication context before destructive68 // Quarantine cancels the session publication context before destructive
68 // repository operations and publicationDone joins a started publication.69 // repository operations and publicationDone joins a started publication.
69- snapshotPending bool70+ snapshotPending bool
70- snapshotPendingCaptures uint71+ snapshotPendingCaptures uint
72+ // snapshotForcedGeneration is the newest dirty generation a forced
73+ // checkpoint must publish. It survives an older routine capture so worker
74+ // completion can enqueue exactly one forced successor.
75+ snapshotForcedGeneration uint64
71 snapshotQueuedCapture *snapshotCapture76 snapshotQueuedCapture *snapshotCapture
72 snapshotInFlightCapture *snapshotCapture77 snapshotInFlightCapture *snapshotCapture
73 snapshotPublicationDone chan struct{}78 snapshotPublicationDone chan struct{}
@@ -809,11 +814,14 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
809 }814 }
810 } else {815 } else {
811 markSnapshotDirty(sess)816 markSnapshotDirty(sess)
812- if !d.scheduleFinalSnapshot(sess) {817+ sess.snapshotMu.Lock()
818+ terminalGeneration := sess.snapshotGeneration
819+ sess.snapshotMu.Unlock()
820+ if !d.scheduleFinalSnapshot(sess) || !d.waitForSnapshotGeneration(sess, terminalGeneration) {
813 sess.mu.Lock()821 sess.mu.Lock()
814 name := sess.name822 name := sess.name
815 sess.mu.Unlock()823 sess.mu.Unlock()
816- terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable or saturated", name)824+ terminalSnapshotErr = fmt.Errorf("retain final snapshot for session %q: snapshot worker unavailable, saturated, or timed out", name)
817 if reason != ports.ReasonServerShutdown {825 if reason != ports.ReasonServerShutdown {
818 return terminalSnapshotErr826 return terminalSnapshotErr
819 }827 }
M internal/usecase/daemon/snapshot.go +61 -5
@@ -385,6 +385,8 @@ func resumeSnapshotCoordinatorForNewIdentity(sess *session, quarantine snapshotC
385385 // dirty and wake the scheduler.
386386 sess.snapshotGeneration = 1
387387 sess.snapshotCapturedGeneration = 0
388+ sess.snapshotPublishedGeneration = 0
389+ sess.snapshotForcedGeneration = 0
388390 sess.snapshotNextEligibleAt = time.Time{}
389391 sess.snapshotAttempted = false
390392 sess.snapshotAttemptKind = snapshotAttemptRoutine
@@ -499,15 +501,22 @@ func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bo
499501 }
500502 sess.snapshotMu.Lock()
501503 routineRateLimited := d.snapshotRepository != nil
502- // Forced attempts coalesce with a routine capture too. This keeps the
503- // coordinator at one queued plus one in-flight capture, while the dirty
504- // generation ensures a later checkpoint observes mutations made meanwhile.
505- if sess.snapshotQuarantined || !sess.snapDirty.Load() || sess.snapshotPending ||
506- (!final && routineRateLimited && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)) {
504+ if sess.snapshotQuarantined || !sess.snapDirty.Load() {
507505 available := !sess.snapshotQuarantined
508506 sess.snapshotMu.Unlock()
509507 return available
510508 }
509+ if final && sess.snapshotGeneration > sess.snapshotForcedGeneration {
510+ // Do not let an older queued or in-flight routine publication satisfy a
511+ // terminal checkpoint. The intent is retained until a publication at
512+ // this generation (or newer) succeeds.
513+ sess.snapshotForcedGeneration = sess.snapshotGeneration
514+ }
515+ if sess.snapshotPending ||
516+ (!final && routineRateLimited && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)) {
517+ sess.snapshotMu.Unlock()
518+ return true
519+ }
511520 if sess.snapshotPublicationContext == nil {
512521 sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())
513522 }
@@ -549,6 +558,32 @@ func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bo
549558 return false
550559 }
551560
561+// waitForSnapshotGeneration waits only for the bounded terminal checkpoint
562+// deadline. It observes successful publication, rather than dirty state, so a
563+// concurrent later mutation cannot turn a completed forced checkpoint into a
564+// false timeout.
565+func (d *Daemon) waitForSnapshotGeneration(sess *session, generation uint64) bool {
566+ if d == nil || sess == nil || generation == 0 {
567+ return true
568+ }
569+ timer := d.clock.NewTimer(snapshotFinalFlushTimeout)
570+ defer timer.Stop()
571+ for {
572+ sess.snapshotMu.Lock()
573+ published := sess.snapshotPublishedGeneration >= generation
574+ changed := sess.snapshotChangeLocked()
575+ sess.snapshotMu.Unlock()
576+ if published {
577+ return true
578+ }
579+ select {
580+ case <-changed:
581+ case <-timer.C():
582+ return false
583+ }
584+ }
585+}
586+
552587 // enqueueSnapshotCapture serializes worker shutdown with the non-blocking
553588 // queue send. The queue is deliberately never closed: producers can race
554589 // shutdown without risking a send-on-closed panic.
@@ -737,6 +772,7 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool)
737772 return
738773 }
739774 capture.finishOnce.Do(func() {
775+ shouldScheduleForcedSuccessor := false
740776 capture.session.snapshotMu.Lock()
741777 if capture.session.snapshotPendingCaptures > 0 {
742778 capture.session.snapshotPendingCaptures--
@@ -758,12 +794,26 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool)
758794 // attempt. A forced teardown checkpoint deliberately leaves it alone.
759795 capture.session.snapshotNextEligibleAt = d.clock.Now().Add(snapshotInterval)
760796 }
797+ if succeeded {
798+ capture.session.snapshotPublishedGeneration = max(capture.session.snapshotPublishedGeneration, capture.generation)
799+ }
761800 if succeeded && capture.session.snapshotGeneration == capture.generation {
762801 capture.session.snapDirty.Store(false)
763802 capture.session.snapshotFailureSig = ""
764803 } else if !succeeded {
765804 capture.session.snapDirty.Store(true)
766805 }
806+ if succeeded && capture.session.snapshotForcedGeneration != 0 &&
807+ capture.generation >= capture.session.snapshotForcedGeneration {
808+ capture.session.snapshotForcedGeneration = 0
809+ }
810+ // A failed forced publication remains dirty for ordinary retry rather
811+ // than recursively creating forced jobs. A later forced request records
812+ // a newer intent and can create its one bounded successor.
813+ shouldScheduleForcedSuccessor = capture.session.snapshotForcedGeneration != 0 &&
814+ capture.generation < capture.session.snapshotForcedGeneration &&
815+ capture.session.snapshotQueuedCapture == nil && capture.session.snapshotInFlightCapture == nil &&
816+ (succeeded || capture.attemptKind != snapshotAttemptForced)
767817 capture.session.signalSnapshotChangedLocked()
768818 wake := capture.session.snapshotWake
769819 capture.session.snapshotMu.Unlock()
@@ -773,6 +823,12 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool)
773823 default:
774824 }
775825 }
826+ if shouldScheduleForcedSuccessor {
827+ // This runs after publication completion and outside snapshotMu. It
828+ // bypasses routine eligibility while preserving the single queued and
829+ // single in-flight capture bound.
830+ _ = d.scheduleFinalSnapshot(capture.session)
831+ }
776832 if succeeded {
777833 d.clearSnapshotFailure()
778834 }
@@ -385,6 +385,8 @@ func resumeSnapshotCoordinatorForNewIdentity(sess *session, quarantine snapshotC
385 // dirty and wake the scheduler.385 // dirty and wake the scheduler.
386 sess.snapshotGeneration = 1386 sess.snapshotGeneration = 1
387 sess.snapshotCapturedGeneration = 0387 sess.snapshotCapturedGeneration = 0
388+ sess.snapshotPublishedGeneration = 0
389+ sess.snapshotForcedGeneration = 0
388 sess.snapshotNextEligibleAt = time.Time{}390 sess.snapshotNextEligibleAt = time.Time{}
389 sess.snapshotAttempted = false391 sess.snapshotAttempted = false
390 sess.snapshotAttemptKind = snapshotAttemptRoutine392 sess.snapshotAttemptKind = snapshotAttemptRoutine
@@ -499,15 +501,22 @@ func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bo
499 }501 }
500 sess.snapshotMu.Lock()502 sess.snapshotMu.Lock()
501 routineRateLimited := d.snapshotRepository != nil503 routineRateLimited := d.snapshotRepository != nil
502- // Forced attempts coalesce with a routine capture too. This keeps the504+ if sess.snapshotQuarantined || !sess.snapDirty.Load() {
503- // coordinator at one queued plus one in-flight capture, while the dirty
504- // generation ensures a later checkpoint observes mutations made meanwhile.
505- if sess.snapshotQuarantined || !sess.snapDirty.Load() || sess.snapshotPending ||
506- (!final && routineRateLimited && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)) {
507 available := !sess.snapshotQuarantined505 available := !sess.snapshotQuarantined
508 sess.snapshotMu.Unlock()506 sess.snapshotMu.Unlock()
509 return available507 return available
510 }508 }
509+ if final && sess.snapshotGeneration > sess.snapshotForcedGeneration {
510+ // Do not let an older queued or in-flight routine publication satisfy a
511+ // terminal checkpoint. The intent is retained until a publication at
512+ // this generation (or newer) succeeds.
513+ sess.snapshotForcedGeneration = sess.snapshotGeneration
514+ }
515+ if sess.snapshotPending ||
516+ (!final && routineRateLimited && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)) {
517+ sess.snapshotMu.Unlock()
518+ return true
519+ }
511 if sess.snapshotPublicationContext == nil {520 if sess.snapshotPublicationContext == nil {
512 sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())521 sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())
513 }522 }
@@ -549,6 +558,32 @@ func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bo
549 return false558 return false
550 }559 }
551 560
561+// waitForSnapshotGeneration waits only for the bounded terminal checkpoint
562+// deadline. It observes successful publication, rather than dirty state, so a
563+// concurrent later mutation cannot turn a completed forced checkpoint into a
564+// false timeout.
565+func (d *Daemon) waitForSnapshotGeneration(sess *session, generation uint64) bool {
566+ if d == nil || sess == nil || generation == 0 {
567+ return true
568+ }
569+ timer := d.clock.NewTimer(snapshotFinalFlushTimeout)
570+ defer timer.Stop()
571+ for {
572+ sess.snapshotMu.Lock()
573+ published := sess.snapshotPublishedGeneration >= generation
574+ changed := sess.snapshotChangeLocked()
575+ sess.snapshotMu.Unlock()
576+ if published {
577+ return true
578+ }
579+ select {
580+ case <-changed:
581+ case <-timer.C():
582+ return false
583+ }
584+ }
585+}
586+
552 // enqueueSnapshotCapture serializes worker shutdown with the non-blocking587 // enqueueSnapshotCapture serializes worker shutdown with the non-blocking
553 // queue send. The queue is deliberately never closed: producers can race588 // queue send. The queue is deliberately never closed: producers can race
554 // shutdown without risking a send-on-closed panic.589 // shutdown without risking a send-on-closed panic.
@@ -737,6 +772,7 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool)
737 return772 return
738 }773 }
739 capture.finishOnce.Do(func() {774 capture.finishOnce.Do(func() {
775+ shouldScheduleForcedSuccessor := false
740 capture.session.snapshotMu.Lock()776 capture.session.snapshotMu.Lock()
741 if capture.session.snapshotPendingCaptures > 0 {777 if capture.session.snapshotPendingCaptures > 0 {
742 capture.session.snapshotPendingCaptures--778 capture.session.snapshotPendingCaptures--
@@ -758,12 +794,26 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool)
758 // attempt. A forced teardown checkpoint deliberately leaves it alone.794 // attempt. A forced teardown checkpoint deliberately leaves it alone.
759 capture.session.snapshotNextEligibleAt = d.clock.Now().Add(snapshotInterval)795 capture.session.snapshotNextEligibleAt = d.clock.Now().Add(snapshotInterval)
760 }796 }
797+ if succeeded {
798+ capture.session.snapshotPublishedGeneration = max(capture.session.snapshotPublishedGeneration, capture.generation)
799+ }
761 if succeeded && capture.session.snapshotGeneration == capture.generation {800 if succeeded && capture.session.snapshotGeneration == capture.generation {
762 capture.session.snapDirty.Store(false)801 capture.session.snapDirty.Store(false)
763 capture.session.snapshotFailureSig = ""802 capture.session.snapshotFailureSig = ""
764 } else if !succeeded {803 } else if !succeeded {
765 capture.session.snapDirty.Store(true)804 capture.session.snapDirty.Store(true)
766 }805 }
806+ if succeeded && capture.session.snapshotForcedGeneration != 0 &&
807+ capture.generation >= capture.session.snapshotForcedGeneration {
808+ capture.session.snapshotForcedGeneration = 0
809+ }
810+ // A failed forced publication remains dirty for ordinary retry rather
811+ // than recursively creating forced jobs. A later forced request records
812+ // a newer intent and can create its one bounded successor.
813+ shouldScheduleForcedSuccessor = capture.session.snapshotForcedGeneration != 0 &&
814+ capture.generation < capture.session.snapshotForcedGeneration &&
815+ capture.session.snapshotQueuedCapture == nil && capture.session.snapshotInFlightCapture == nil &&
816+ (succeeded || capture.attemptKind != snapshotAttemptForced)
767 capture.session.signalSnapshotChangedLocked()817 capture.session.signalSnapshotChangedLocked()
768 wake := capture.session.snapshotWake818 wake := capture.session.snapshotWake
769 capture.session.snapshotMu.Unlock()819 capture.session.snapshotMu.Unlock()
@@ -773,6 +823,12 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool)
773 default:823 default:
774 }824 }
775 }825 }
826+ if shouldScheduleForcedSuccessor {
827+ // This runs after publication completion and outside snapshotMu. It
828+ // bypasses routine eligibility while preserving the single queued and
829+ // single in-flight capture bound.
830+ _ = d.scheduleFinalSnapshot(capture.session)
831+ }
776 if succeeded {832 if succeeded {
777 d.clearSnapshotFailure()833 d.clearSnapshotFailure()
778 }834 }
M internal/usecase/daemon/snapshot_scheduler_test.go +210 -0
@@ -195,6 +195,183 @@ func TestRenameRollbackAfterOldDeleteLeavesOldCoordinatorStopped(t *testing.T) {
195195 }
196196 }
197197
198+func TestForcedSnapshotSchedulesSuccessorAfterRoutineInFlight(t *testing.T) {
199+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
200+ repository := portsmocks.NewMockSnapshotRepository(t)
201+ WithSnapshotRepository(repository, nil)(d)
202+ startSnapshotEncodeWorker(t, d)
203+
204+ started := make(chan struct{})
205+ release := make(chan struct{})
206+ publications := make(chan ports.SnapshotPublication, 2)
207+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
208+ publications <- publication
209+ if publication.Generation == 1 {
210+ close(started)
211+ <-release
212+ }
213+ return nil
214+ }).Twice()
215+
216+ sess := newSnapshotTestSession(t, "work", false, "/work")
217+ markSnapshotDirty(sess)
218+ require.True(t, d.scheduleSnapshot(sess))
219+ select {
220+ case <-started:
221+ case <-time.After(testWaitTimeout):
222+ t.Fatal("routine publication did not start")
223+ }
224+
225+ // The terminal state changed after the routine capture, so its publication
226+ // cannot satisfy the forced checkpoint.
227+ sess.tabs[0].panes["pane-1"].screen.Write([]byte(" terminal"))
228+ markSnapshotDirty(sess)
229+ require.True(t, d.scheduleFinalSnapshot(sess))
230+ close(release)
231+
232+ awaitSnapshotClean(t, sess)
233+ require.Equal(t, uint64(1), (<-publications).Generation)
234+ require.Equal(t, uint64(2), (<-publications).Generation)
235+}
236+
237+func TestForcedSnapshotSchedulesSuccessorForRoutineCaptureQueuedBehindWorker(t *testing.T) {
238+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
239+ repository := portsmocks.NewMockSnapshotRepository(t)
240+ WithSnapshotRepository(repository, nil)(d)
241+ startSnapshotEncodeWorker(t, d)
242+
243+ blockerStarted := make(chan struct{})
244+ releaseBlocker := make(chan struct{})
245+ workPublications := make(chan ports.SnapshotPublication, 2)
246+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
247+ if publication.Name == "blocker" {
248+ close(blockerStarted)
249+ <-releaseBlocker
250+ return nil
251+ }
252+ workPublications <- publication
253+ return nil
254+ }).Times(3)
255+
256+ blocker := newSnapshotTestSession(t, "blocker", false, "/work")
257+ markSnapshotDirty(blocker)
258+ require.True(t, d.scheduleSnapshot(blocker))
259+ select {
260+ case <-blockerStarted:
261+ case <-time.After(testWaitTimeout):
262+ t.Fatal("blocking publication did not start")
263+ }
264+
265+ sess := newSnapshotTestSession(t, "work", false, "/work")
266+ markSnapshotDirty(sess)
267+ require.True(t, d.scheduleSnapshot(sess), "routine capture should occupy the bounded queue")
268+ sess.tabs[0].panes["pane-1"].screen.Write([]byte(" terminal"))
269+ markSnapshotDirty(sess)
270+ require.True(t, d.scheduleFinalSnapshot(sess))
271+ close(releaseBlocker)
272+
273+ awaitSnapshotClean(t, sess)
274+ require.Equal(t, uint64(1), (<-workPublications).Generation)
275+ require.Equal(t, uint64(2), (<-workPublications).Generation)
276+}
277+
278+func TestMultipleForcedSnapshotsCoalesceToOneSuccessor(t *testing.T) {
279+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
280+ repository := portsmocks.NewMockSnapshotRepository(t)
281+ WithSnapshotRepository(repository, nil)(d)
282+ startSnapshotEncodeWorker(t, d)
283+
284+ started := make(chan struct{})
285+ release := make(chan struct{})
286+ publications := make(chan ports.SnapshotPublication, 2)
287+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
288+ publications <- publication
289+ if publication.Generation == 1 {
290+ close(started)
291+ <-release
292+ }
293+ return nil
294+ }).Twice()
295+
296+ sess := newSnapshotTestSession(t, "work", false, "/work")
297+ markSnapshotDirty(sess)
298+ require.True(t, d.scheduleSnapshot(sess))
299+ select {
300+ case <-started:
301+ case <-time.After(testWaitTimeout):
302+ t.Fatal("routine publication did not start")
303+ }
304+ markSnapshotDirty(sess)
305+ require.True(t, d.scheduleFinalSnapshot(sess))
306+ markSnapshotDirty(sess)
307+ require.True(t, d.scheduleFinalSnapshot(sess))
308+ close(release)
309+
310+ awaitSnapshotClean(t, sess)
311+ require.Equal(t, uint64(1), (<-publications).Generation)
312+ require.Equal(t, uint64(3), (<-publications).Generation)
313+ sess.snapshotMu.Lock()
314+ require.False(t, sess.snapshotPending)
315+ require.Nil(t, sess.snapshotQueuedCapture)
316+ require.Nil(t, sess.snapshotInFlightCapture)
317+ sess.snapshotMu.Unlock()
318+}
319+
320+func TestForcedSnapshotShutdownTimeoutRetainsRetryableStateAndNotice(t *testing.T) {
321+ clock := newSnapshotDeadlineClock()
322+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
323+ repository := portsmocks.NewMockSnapshotRepository(t)
324+ WithSnapshotRepository(repository, nil)(d)
325+ notices := portsmocks.NewMockNoticeStore(t)
326+ WithNoticeStore(notices)(d)
327+ startSnapshotEncodeWorker(t, d)
328+
329+ blockerStarted := make(chan struct{})
330+ releaseBlocker := make(chan struct{})
331+ defer close(releaseBlocker)
332+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
333+ if publication.Name == "blocker" {
334+ close(blockerStarted)
335+ <-releaseBlocker
336+ }
337+ return nil
338+ }).Maybe()
339+ notices.EXPECT().Append(mock.MatchedBy(func(notification domain.Notification) bool {
340+ return notification.Code == domain.NoticeSnapshotWrite && notification.Severity == domain.NoticeError
341+ })).Return(nil).Once()
342+
343+ blocker := newSnapshotTestSession(t, "blocker", false, "/work")
344+ markSnapshotDirty(blocker)
345+ require.True(t, d.scheduleSnapshot(blocker))
346+ select {
347+ case <-blockerStarted:
348+ case <-time.After(testWaitTimeout):
349+ t.Fatal("blocking publication did not start")
350+ }
351+
352+ sess := newSnapshotTestSession(t, "work", false, "/work")
353+ pty := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
354+ pty.EXPECT().Close().Return(nil).Maybe()
355+ d.sessions[sess.id] = sess
356+ markSnapshotDirty(sess)
357+ require.True(t, d.scheduleSnapshot(sess), "routine capture should occupy the bounded queue")
358+
359+ killed := make(chan error, 1)
360+ go func() { killed <- d.killSession(sess, ports.ReasonServerShutdown, false) }()
361+ timer := clock.nextTimer(t)
362+ timer.fire()
363+ require.Error(t, <-killed)
364+
365+ sess.snapshotMu.Lock()
366+ require.True(t, sess.snapDirty.Load())
367+ require.Greater(t, sess.snapshotForcedGeneration, sess.snapshotPublishedGeneration)
368+ require.NotNil(t, sess.snapshotQueuedCapture, "the one routine capture remains retryable")
369+ sess.snapshotMu.Unlock()
370+ d.snapshotWorkerMu.Lock()
371+ require.LessOrEqual(t, len(d.snapshotJobs), snapshotQueueCapacity)
372+ d.snapshotWorkerMu.Unlock()
373+}
374+
198375 func TestRoutineSnapshotEligibilityStartsAtCompletionAndForcedDoesNotMoveIt(t *testing.T) {
199376 clock := &snapshotSchedulerClock{now: time.Unix(100, 0)}
200377 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
@@ -241,3 +418,36 @@ func (c *snapshotSchedulerClock) Now() time.Time {
241418 }
242419
243420 func (*snapshotSchedulerClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
421+
422+type snapshotDeadlineClock struct {
423+ timers chan *snapshotDeadlineTimer
424+}
425+
426+func newSnapshotDeadlineClock() *snapshotDeadlineClock {
427+ return &snapshotDeadlineClock{timers: make(chan *snapshotDeadlineTimer, 4)}
428+}
429+
430+func (*snapshotDeadlineClock) Now() time.Time { return time.Unix(100, 0) }
431+func (c *snapshotDeadlineClock) NewTimer(time.Duration) ports.Timer {
432+ timer := &snapshotDeadlineTimer{ch: make(chan time.Time, 1)}
433+ c.timers <- timer
434+ return timer
435+}
436+
437+func (c *snapshotDeadlineClock) nextTimer(t *testing.T) *snapshotDeadlineTimer {
438+ t.Helper()
439+ select {
440+ case timer := <-c.timers:
441+ return timer
442+ case <-time.After(testWaitTimeout):
443+ t.Fatal("snapshot deadline timer was not created")
444+ return nil
445+ }
446+}
447+
448+type snapshotDeadlineTimer struct{ ch chan time.Time }
449+
450+func (t *snapshotDeadlineTimer) C() <-chan time.Time { return t.ch }
451+func (*snapshotDeadlineTimer) Reset(time.Duration) bool { return true }
452+func (*snapshotDeadlineTimer) Stop() bool { return true }
453+func (t *snapshotDeadlineTimer) fire() { t.ch <- time.Unix(101, 0) }
@@ -195,6 +195,183 @@ func TestRenameRollbackAfterOldDeleteLeavesOldCoordinatorStopped(t *testing.T) {
195 }195 }
196 }196 }
197 197
198+func TestForcedSnapshotSchedulesSuccessorAfterRoutineInFlight(t *testing.T) {
199+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
200+ repository := portsmocks.NewMockSnapshotRepository(t)
201+ WithSnapshotRepository(repository, nil)(d)
202+ startSnapshotEncodeWorker(t, d)
203+
204+ started := make(chan struct{})
205+ release := make(chan struct{})
206+ publications := make(chan ports.SnapshotPublication, 2)
207+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
208+ publications <- publication
209+ if publication.Generation == 1 {
210+ close(started)
211+ <-release
212+ }
213+ return nil
214+ }).Twice()
215+
216+ sess := newSnapshotTestSession(t, "work", false, "/work")
217+ markSnapshotDirty(sess)
218+ require.True(t, d.scheduleSnapshot(sess))
219+ select {
220+ case <-started:
221+ case <-time.After(testWaitTimeout):
222+ t.Fatal("routine publication did not start")
223+ }
224+
225+ // The terminal state changed after the routine capture, so its publication
226+ // cannot satisfy the forced checkpoint.
227+ sess.tabs[0].panes["pane-1"].screen.Write([]byte(" terminal"))
228+ markSnapshotDirty(sess)
229+ require.True(t, d.scheduleFinalSnapshot(sess))
230+ close(release)
231+
232+ awaitSnapshotClean(t, sess)
233+ require.Equal(t, uint64(1), (<-publications).Generation)
234+ require.Equal(t, uint64(2), (<-publications).Generation)
235+}
236+
237+func TestForcedSnapshotSchedulesSuccessorForRoutineCaptureQueuedBehindWorker(t *testing.T) {
238+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
239+ repository := portsmocks.NewMockSnapshotRepository(t)
240+ WithSnapshotRepository(repository, nil)(d)
241+ startSnapshotEncodeWorker(t, d)
242+
243+ blockerStarted := make(chan struct{})
244+ releaseBlocker := make(chan struct{})
245+ workPublications := make(chan ports.SnapshotPublication, 2)
246+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
247+ if publication.Name == "blocker" {
248+ close(blockerStarted)
249+ <-releaseBlocker
250+ return nil
251+ }
252+ workPublications <- publication
253+ return nil
254+ }).Times(3)
255+
256+ blocker := newSnapshotTestSession(t, "blocker", false, "/work")
257+ markSnapshotDirty(blocker)
258+ require.True(t, d.scheduleSnapshot(blocker))
259+ select {
260+ case <-blockerStarted:
261+ case <-time.After(testWaitTimeout):
262+ t.Fatal("blocking publication did not start")
263+ }
264+
265+ sess := newSnapshotTestSession(t, "work", false, "/work")
266+ markSnapshotDirty(sess)
267+ require.True(t, d.scheduleSnapshot(sess), "routine capture should occupy the bounded queue")
268+ sess.tabs[0].panes["pane-1"].screen.Write([]byte(" terminal"))
269+ markSnapshotDirty(sess)
270+ require.True(t, d.scheduleFinalSnapshot(sess))
271+ close(releaseBlocker)
272+
273+ awaitSnapshotClean(t, sess)
274+ require.Equal(t, uint64(1), (<-workPublications).Generation)
275+ require.Equal(t, uint64(2), (<-workPublications).Generation)
276+}
277+
278+func TestMultipleForcedSnapshotsCoalesceToOneSuccessor(t *testing.T) {
279+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
280+ repository := portsmocks.NewMockSnapshotRepository(t)
281+ WithSnapshotRepository(repository, nil)(d)
282+ startSnapshotEncodeWorker(t, d)
283+
284+ started := make(chan struct{})
285+ release := make(chan struct{})
286+ publications := make(chan ports.SnapshotPublication, 2)
287+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
288+ publications <- publication
289+ if publication.Generation == 1 {
290+ close(started)
291+ <-release
292+ }
293+ return nil
294+ }).Twice()
295+
296+ sess := newSnapshotTestSession(t, "work", false, "/work")
297+ markSnapshotDirty(sess)
298+ require.True(t, d.scheduleSnapshot(sess))
299+ select {
300+ case <-started:
301+ case <-time.After(testWaitTimeout):
302+ t.Fatal("routine publication did not start")
303+ }
304+ markSnapshotDirty(sess)
305+ require.True(t, d.scheduleFinalSnapshot(sess))
306+ markSnapshotDirty(sess)
307+ require.True(t, d.scheduleFinalSnapshot(sess))
308+ close(release)
309+
310+ awaitSnapshotClean(t, sess)
311+ require.Equal(t, uint64(1), (<-publications).Generation)
312+ require.Equal(t, uint64(3), (<-publications).Generation)
313+ sess.snapshotMu.Lock()
314+ require.False(t, sess.snapshotPending)
315+ require.Nil(t, sess.snapshotQueuedCapture)
316+ require.Nil(t, sess.snapshotInFlightCapture)
317+ sess.snapshotMu.Unlock()
318+}
319+
320+func TestForcedSnapshotShutdownTimeoutRetainsRetryableStateAndNotice(t *testing.T) {
321+ clock := newSnapshotDeadlineClock()
322+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
323+ repository := portsmocks.NewMockSnapshotRepository(t)
324+ WithSnapshotRepository(repository, nil)(d)
325+ notices := portsmocks.NewMockNoticeStore(t)
326+ WithNoticeStore(notices)(d)
327+ startSnapshotEncodeWorker(t, d)
328+
329+ blockerStarted := make(chan struct{})
330+ releaseBlocker := make(chan struct{})
331+ defer close(releaseBlocker)
332+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
333+ if publication.Name == "blocker" {
334+ close(blockerStarted)
335+ <-releaseBlocker
336+ }
337+ return nil
338+ }).Maybe()
339+ notices.EXPECT().Append(mock.MatchedBy(func(notification domain.Notification) bool {
340+ return notification.Code == domain.NoticeSnapshotWrite && notification.Severity == domain.NoticeError
341+ })).Return(nil).Once()
342+
343+ blocker := newSnapshotTestSession(t, "blocker", false, "/work")
344+ markSnapshotDirty(blocker)
345+ require.True(t, d.scheduleSnapshot(blocker))
346+ select {
347+ case <-blockerStarted:
348+ case <-time.After(testWaitTimeout):
349+ t.Fatal("blocking publication did not start")
350+ }
351+
352+ sess := newSnapshotTestSession(t, "work", false, "/work")
353+ pty := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
354+ pty.EXPECT().Close().Return(nil).Maybe()
355+ d.sessions[sess.id] = sess
356+ markSnapshotDirty(sess)
357+ require.True(t, d.scheduleSnapshot(sess), "routine capture should occupy the bounded queue")
358+
359+ killed := make(chan error, 1)
360+ go func() { killed <- d.killSession(sess, ports.ReasonServerShutdown, false) }()
361+ timer := clock.nextTimer(t)
362+ timer.fire()
363+ require.Error(t, <-killed)
364+
365+ sess.snapshotMu.Lock()
366+ require.True(t, sess.snapDirty.Load())
367+ require.Greater(t, sess.snapshotForcedGeneration, sess.snapshotPublishedGeneration)
368+ require.NotNil(t, sess.snapshotQueuedCapture, "the one routine capture remains retryable")
369+ sess.snapshotMu.Unlock()
370+ d.snapshotWorkerMu.Lock()
371+ require.LessOrEqual(t, len(d.snapshotJobs), snapshotQueueCapacity)
372+ d.snapshotWorkerMu.Unlock()
373+}
374+
198 func TestRoutineSnapshotEligibilityStartsAtCompletionAndForcedDoesNotMoveIt(t *testing.T) {375 func TestRoutineSnapshotEligibilityStartsAtCompletionAndForcedDoesNotMoveIt(t *testing.T) {
199 clock := &snapshotSchedulerClock{now: time.Unix(100, 0)}376 clock := &snapshotSchedulerClock{now: time.Unix(100, 0)}
200 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)377 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
@@ -241,3 +418,36 @@ func (c *snapshotSchedulerClock) Now() time.Time {
241 }418 }
242 419
243 func (*snapshotSchedulerClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }420 func (*snapshotSchedulerClock) NewTimer(time.Duration) ports.Timer { return stubTimer{} }
421+
422+type snapshotDeadlineClock struct {
423+ timers chan *snapshotDeadlineTimer
424+}
425+
426+func newSnapshotDeadlineClock() *snapshotDeadlineClock {
427+ return &snapshotDeadlineClock{timers: make(chan *snapshotDeadlineTimer, 4)}
428+}
429+
430+func (*snapshotDeadlineClock) Now() time.Time { return time.Unix(100, 0) }
431+func (c *snapshotDeadlineClock) NewTimer(time.Duration) ports.Timer {
432+ timer := &snapshotDeadlineTimer{ch: make(chan time.Time, 1)}
433+ c.timers <- timer
434+ return timer
435+}
436+
437+func (c *snapshotDeadlineClock) nextTimer(t *testing.T) *snapshotDeadlineTimer {
438+ t.Helper()
439+ select {
440+ case timer := <-c.timers:
441+ return timer
442+ case <-time.After(testWaitTimeout):
443+ t.Fatal("snapshot deadline timer was not created")
444+ return nil
445+ }
446+}
447+
448+type snapshotDeadlineTimer struct{ ch chan time.Time }
449+
450+func (t *snapshotDeadlineTimer) C() <-chan time.Time { return t.ch }
451+func (*snapshotDeadlineTimer) Reset(time.Duration) bool { return true }
452+func (*snapshotDeadlineTimer) Stop() bool { return true }
453+func (t *snapshotDeadlineTimer) fire() { t.ch <- time.Unix(101, 0) }

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