detached d32ee68

fix(daemon): coordinate incremental checkpoints

commit
d32ee68665fd779dfa3445cb85ff3044417d266d
author
brice · · 2026-07-21 22:18 UTC
committer
brice · · 2026-07-21 22:19 UTC
parent
73a5ab3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +262 -32
M internal/usecase/daemon/session.go +65 -16
@@ -43,6 +43,7 @@ type session struct {
4343 client *attachedClient
4444 clipboardQueue []clipboardForward
4545 clipboardWorkerRunning bool
46+ renameInProgress bool
4647 cwd string
4748 terminal terminalEnv
4849 // env is the authoritative immutable environment snapshot for future PTY children.
@@ -61,8 +62,18 @@ type session struct {
6162 snapshotNextEligibleAt time.Time
6263 snapshotAttempted bool
6364 snapshotAttemptKind snapshotAttemptKind
65+ // 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+ // Quarantine cancels the session publication context before destructive
68+ // repository operations and publicationDone joins a started publication.
6469 snapshotPending bool
6570 snapshotPendingCaptures uint
71+ snapshotQueuedCapture *snapshotCapture
72+ snapshotInFlightCapture *snapshotCapture
73+ snapshotPublicationDone chan struct{}
74+ snapshotPublicationContext context.Context
75+ snapshotPublicationCancel context.CancelFunc
76+ snapshotQuarantined bool
6677 // snapshotFailureSig is the stable class of the active failed publication.
6778 // It is guarded by snapshotMu and deliberately never contains error text.
6879 snapshotFailureSig string
@@ -530,15 +541,27 @@ func (d *Daemon) renameSession(sess *session, name string) error {
530541 if err := domain.ValidateSessionName(name); err != nil {
531542 return err
532543 }
544+
545+ // Reserve the rename while state is inspected, then perform persistence and
546+ // repository I/O without daemon/session locks. In particular, Delete must
547+ // follow coordinator quarantine+join or a late Publish could recreate the
548+ // old repository identity.
533549 d.mu.Lock()
534- defer d.mu.Unlock()
535550 if taken := d.findByNameLocked(name); taken != nil && taken != sess {
551+ d.mu.Unlock()
536552 return errSessionNameInUse
537553 }
554+ sess.mu.Lock()
555+ if sess.renameInProgress {
556+ sess.mu.Unlock()
557+ d.mu.Unlock()
558+ return errors.New("session rename already in progress")
559+ }
538560 if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name {
561+ sess.mu.Unlock()
562+ d.mu.Unlock()
539563 return errSessionNameInUse
540564 }
541- sess.mu.Lock()
542565 oldName := sess.name
543566 wasEphemeral := sess.ephemeral
544567 createdAt := sess.createdAt
@@ -547,45 +570,65 @@ func (d *Daemon) renameSession(sess *session, name string) error {
547570 createdAt, err = d.allocateLifecycleCreatedAtLocked()
548571 if err != nil {
549572 sess.mu.Unlock()
573+ d.mu.Unlock()
550574 return err
551575 }
552576 }
553577 lastUsedSeq := sess.mruAt.Load()
578+ record := sess.persistRecordLocked(d.nowUnixNano())
579+ record.Name = name
580+ record.CreatedAt = createdAt
581+ record.LastUsedSeq = lastUsedSeq
582+ sess.renameInProgress = true
583+ sess.mu.Unlock()
584+ d.mu.Unlock()
585+
586+ rollback := func(err error, resume bool) error {
587+ if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
588+ d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
589+ }
590+ if resume {
591+ resumeSnapshotCoordinator(sess)
592+ }
593+ sess.mu.Lock()
594+ sess.renameInProgress = false
595+ sess.mu.Unlock()
596+ return err
597+ }
554598 if wasEphemeral || oldName != name {
555- record := sess.persistRecordLocked(d.nowUnixNano())
556- record.Name = name
557- record.CreatedAt = createdAt
558- record.LastUsedSeq = lastUsedSeq
559599 if err := d.persist.Save(record); err != nil {
600+ sess.mu.Lock()
601+ sess.renameInProgress = false
560602 sess.mu.Unlock()
561603 return err
562604 }
563605 }
564606 if !wasEphemeral && oldName != name {
607+ <-quarantineSnapshotCoordinator(sess)
565608 if d.snapsEnabled && d.snapshotRepository != nil {
566609 if err := d.snapshotRepository.Delete(context.Background(), oldName); err != nil {
567- if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
568- d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
569- }
570- sess.mu.Unlock()
571- return err
610+ return rollback(err, true)
572611 }
573612 }
574613 if err := d.persist.Delete(oldName); err != nil {
575- if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
576- d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
577- }
578- sess.mu.Unlock()
579- return err
614+ return rollback(err, true)
580615 }
616+ resumeSnapshotCoordinator(sess)
581617 }
618+
619+ d.mu.Lock()
620+ sess.mu.Lock()
621+ // The reservation prevents another rename. A closing session is allowed to
622+ // finish this short commit; killSession subsequently owns its teardown.
582623 delete(d.stopped, oldName)
583624 delete(d.stopped, name)
584625 sess.name = name
585626 sess.createdAt = createdAt
586627 sess.ephemeral = false
628+ sess.renameInProgress = false
587629 sess.snapEligible.Store(name != "")
588630 sess.mu.Unlock()
631+ d.mu.Unlock()
589632 markSnapshotDirty(sess)
590633 return nil
591634 }
@@ -731,6 +774,12 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
731774 sess.mu.Lock()
732775 isEphemeral := sess.ephemeral
733776 sess.mu.Unlock()
777+ // A repository delete must never race an older publication that could
778+ // recreate the just-quarantined name. Do not hold daemon or session locks
779+ // while joining: a repository implementation may ignore cancellation.
780+ if purge && !isEphemeral && d.snapshotRepository != nil {
781+ <-quarantineSnapshotCoordinator(sess)
782+ }
734783 if !purge && !isEphemeral && d.persistEnabled {
735784 d.refreshSessionCwd(sess)
736785 }
@@ -43,6 +43,7 @@ type session struct {
43 client *attachedClient43 client *attachedClient
44 clipboardQueue []clipboardForward44 clipboardQueue []clipboardForward
45 clipboardWorkerRunning bool45 clipboardWorkerRunning bool
46+ renameInProgress bool
46 cwd string47 cwd string
47 terminal terminalEnv48 terminal terminalEnv
48 // env is the authoritative immutable environment snapshot for future PTY children.49 // env is the authoritative immutable environment snapshot for future PTY children.
@@ -61,8 +62,18 @@ type session struct {
61 snapshotNextEligibleAt time.Time62 snapshotNextEligibleAt time.Time
62 snapshotAttempted bool63 snapshotAttempted bool
63 snapshotAttemptKind snapshotAttemptKind64 snapshotAttemptKind snapshotAttemptKind
65+ // 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+ // Quarantine cancels the session publication context before destructive
68+ // repository operations and publicationDone joins a started publication.
64 snapshotPending bool69 snapshotPending bool
65 snapshotPendingCaptures uint70 snapshotPendingCaptures uint
71+ snapshotQueuedCapture *snapshotCapture
72+ snapshotInFlightCapture *snapshotCapture
73+ snapshotPublicationDone chan struct{}
74+ snapshotPublicationContext context.Context
75+ snapshotPublicationCancel context.CancelFunc
76+ snapshotQuarantined bool
66 // snapshotFailureSig is the stable class of the active failed publication.77 // snapshotFailureSig is the stable class of the active failed publication.
67 // It is guarded by snapshotMu and deliberately never contains error text.78 // It is guarded by snapshotMu and deliberately never contains error text.
68 snapshotFailureSig string79 snapshotFailureSig string
@@ -530,15 +541,27 @@ func (d *Daemon) renameSession(sess *session, name string) error {
530 if err := domain.ValidateSessionName(name); err != nil {541 if err := domain.ValidateSessionName(name); err != nil {
531 return err542 return err
532 }543 }
544+
545+ // Reserve the rename while state is inspected, then perform persistence and
546+ // repository I/O without daemon/session locks. In particular, Delete must
547+ // follow coordinator quarantine+join or a late Publish could recreate the
548+ // old repository identity.
533 d.mu.Lock()549 d.mu.Lock()
534- defer d.mu.Unlock()
535 if taken := d.findByNameLocked(name); taken != nil && taken != sess {550 if taken := d.findByNameLocked(name); taken != nil && taken != sess {
551+ d.mu.Unlock()
536 return errSessionNameInUse552 return errSessionNameInUse
537 }553 }
554+ sess.mu.Lock()
555+ if sess.renameInProgress {
556+ sess.mu.Unlock()
557+ d.mu.Unlock()
558+ return errors.New("session rename already in progress")
559+ }
538 if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name {560 if stopped, ok := d.stopped[name]; ok && stopped.name != sess.name {
561+ sess.mu.Unlock()
562+ d.mu.Unlock()
539 return errSessionNameInUse563 return errSessionNameInUse
540 }564 }
541- sess.mu.Lock()
542 oldName := sess.name565 oldName := sess.name
543 wasEphemeral := sess.ephemeral566 wasEphemeral := sess.ephemeral
544 createdAt := sess.createdAt567 createdAt := sess.createdAt
@@ -547,45 +570,65 @@ func (d *Daemon) renameSession(sess *session, name string) error {
547 createdAt, err = d.allocateLifecycleCreatedAtLocked()570 createdAt, err = d.allocateLifecycleCreatedAtLocked()
548 if err != nil {571 if err != nil {
549 sess.mu.Unlock()572 sess.mu.Unlock()
573+ d.mu.Unlock()
550 return err574 return err
551 }575 }
552 }576 }
553 lastUsedSeq := sess.mruAt.Load()577 lastUsedSeq := sess.mruAt.Load()
578+ record := sess.persistRecordLocked(d.nowUnixNano())
579+ record.Name = name
580+ record.CreatedAt = createdAt
581+ record.LastUsedSeq = lastUsedSeq
582+ sess.renameInProgress = true
583+ sess.mu.Unlock()
584+ d.mu.Unlock()
585+
586+ rollback := func(err error, resume bool) error {
587+ if cleanupErr := d.persist.Delete(name); cleanupErr != nil {
588+ d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
589+ }
590+ if resume {
591+ resumeSnapshotCoordinator(sess)
592+ }
593+ sess.mu.Lock()
594+ sess.renameInProgress = false
595+ sess.mu.Unlock()
596+ return err
597+ }
554 if wasEphemeral || oldName != name {598 if wasEphemeral || oldName != name {
555- record := sess.persistRecordLocked(d.nowUnixNano())
556- record.Name = name
557- record.CreatedAt = createdAt
558- record.LastUsedSeq = lastUsedSeq
559 if err := d.persist.Save(record); err != nil {599 if err := d.persist.Save(record); err != nil {
600+ sess.mu.Lock()
601+ sess.renameInProgress = false
560 sess.mu.Unlock()602 sess.mu.Unlock()
561 return err603 return err
562 }604 }
563 }605 }
564 if !wasEphemeral && oldName != name {606 if !wasEphemeral && oldName != name {
607+ <-quarantineSnapshotCoordinator(sess)
565 if d.snapsEnabled && d.snapshotRepository != nil {608 if d.snapsEnabled && d.snapshotRepository != nil {
566 if err := d.snapshotRepository.Delete(context.Background(), oldName); err != nil {609 if err := d.snapshotRepository.Delete(context.Background(), oldName); err != nil {
567- if cleanupErr := d.persist.Delete(name); cleanupErr != nil {610+ return rollback(err, true)
568- d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
569- }
570- sess.mu.Unlock()
571- return err
572 }611 }
573 }612 }
574 if err := d.persist.Delete(oldName); err != nil {613 if err := d.persist.Delete(oldName); err != nil {
575- if cleanupErr := d.persist.Delete(name); cleanupErr != nil {614+ return rollback(err, true)
576- d.log.Warn("cleaning up renamed persisted session failed", "err", cleanupErr, "session", name)
577- }
578- sess.mu.Unlock()
579- return err
580 }615 }
616+ resumeSnapshotCoordinator(sess)
581 }617 }
618+
619+ d.mu.Lock()
620+ sess.mu.Lock()
621+ // The reservation prevents another rename. A closing session is allowed to
622+ // finish this short commit; killSession subsequently owns its teardown.
582 delete(d.stopped, oldName)623 delete(d.stopped, oldName)
583 delete(d.stopped, name)624 delete(d.stopped, name)
584 sess.name = name625 sess.name = name
585 sess.createdAt = createdAt626 sess.createdAt = createdAt
586 sess.ephemeral = false627 sess.ephemeral = false
628+ sess.renameInProgress = false
587 sess.snapEligible.Store(name != "")629 sess.snapEligible.Store(name != "")
588 sess.mu.Unlock()630 sess.mu.Unlock()
631+ d.mu.Unlock()
589 markSnapshotDirty(sess)632 markSnapshotDirty(sess)
590 return nil633 return nil
591 }634 }
@@ -731,6 +774,12 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
731 sess.mu.Lock()774 sess.mu.Lock()
732 isEphemeral := sess.ephemeral775 isEphemeral := sess.ephemeral
733 sess.mu.Unlock()776 sess.mu.Unlock()
777+ // A repository delete must never race an older publication that could
778+ // recreate the just-quarantined name. Do not hold daemon or session locks
779+ // while joining: a repository implementation may ignore cancellation.
780+ if purge && !isEphemeral && d.snapshotRepository != nil {
781+ <-quarantineSnapshotCoordinator(sess)
782+ }
734 if !purge && !isEphemeral && d.persistEnabled {783 if !purge && !isEphemeral && d.persistEnabled {
735 d.refreshSessionCwd(sess)784 d.refreshSessionCwd(sess)
736 }785 }
M internal/usecase/daemon/snapshot.go +143 -16
@@ -34,14 +34,16 @@ const (
3434 )
3535
3636 type snapshotCapture struct {
37- session *session
38- attemptKind snapshotAttemptKind
39- generation uint64
40- name string
41- createdAt uint64
42- active uint16
43- tabs []snapshotCaptureTab
44- finishOnce sync.Once
37+ session *session
38+ attemptKind snapshotAttemptKind
39+ generation uint64
40+ name string
41+ createdAt uint64
42+ active uint16
43+ tabs []snapshotCaptureTab
44+ publicationContext context.Context
45+ coordinatorDiscarded bool // guarded by session.snapshotMu
46+ finishOnce sync.Once
4547 }
4648
4749 type snapshotCaptureTab struct {
@@ -299,6 +301,72 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
299301 return nil
300302 }
301303
304+// snapshotCoordinatorContext returns the cancellation context shared by this
305+// session's queued and in-flight captures. snapshotMu serializes creation with
306+// quarantine, so a publisher can never obtain a live context after quarantine.
307+func snapshotCoordinatorContext(sess *session) (context.Context, context.CancelFunc) {
308+ sess.snapshotMu.Lock()
309+ defer sess.snapshotMu.Unlock()
310+ if sess.snapshotPublicationContext == nil {
311+ sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())
312+ }
313+ return sess.snapshotPublicationContext, sess.snapshotPublicationCancel
314+}
315+
316+// quarantineSnapshotCoordinator prevents a deleted session from starting any
317+// further publication. The returned join channel is closed after an already
318+// started repository call returns; callers must wait without daemon or session
319+// locks before deleting repository state.
320+func quarantineSnapshotCoordinator(sess *session) <-chan struct{} {
321+ if sess == nil {
322+ done := make(chan struct{})
323+ close(done)
324+ return done
325+ }
326+ sess.snapshotMu.Lock()
327+ sess.snapshotQuarantined = true
328+ sess.snapEligible.Store(false)
329+ if sess.snapshotPublicationCancel != nil {
330+ sess.snapshotPublicationCancel()
331+ }
332+ // Remove a not-yet-started capture from the coordinator immediately. The
333+ // global queue may still contain it, but startSnapshotPublication compares
334+ // identity and will discard it even if this session is later renamed.
335+ if sess.snapshotQueuedCapture != nil {
336+ sess.snapshotQueuedCapture.coordinatorDiscarded = true
337+ sess.snapshotQueuedCapture = nil
338+ if sess.snapshotPendingCaptures > 0 {
339+ sess.snapshotPendingCaptures--
340+ }
341+ sess.snapshotPending = sess.snapshotPendingCaptures > 0 || sess.snapshotInFlightCapture != nil
342+ }
343+ done := sess.snapshotPublicationDone
344+ if done == nil {
345+ done = make(chan struct{})
346+ close(done)
347+ }
348+ sess.signalSnapshotChangedLocked()
349+ sess.snapshotMu.Unlock()
350+ return done
351+}
352+
353+// resumeSnapshotCoordinator is used only after a rename's old repository name
354+// has been quarantined and deleted. Queued captures were discarded during
355+// quarantine, so the new context cannot publish under the old identity.
356+func resumeSnapshotCoordinator(sess *session) {
357+ if sess == nil {
358+ return
359+ }
360+ sess.snapshotMu.Lock()
361+ if sess.snapshotPublicationCancel != nil {
362+ sess.snapshotPublicationCancel()
363+ }
364+ sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())
365+ sess.snapshotQuarantined = false
366+ sess.signalSnapshotChangedLocked()
367+ sess.snapshotMu.Unlock()
368+}
369+
302370 func markSnapshotDirty(sess *session) {
303371 if sess == nil || !sess.snapEligible.Load() {
304372 return
@@ -395,11 +463,20 @@ func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bo
395463 }
396464 sess.snapshotMu.Lock()
397465 routineRateLimited := d.snapshotRepository != nil
398- if !sess.snapDirty.Load() || (!final && (sess.snapshotPending || (routineRateLimited && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)))) {
466+ // Forced attempts coalesce with a routine capture too. This keeps the
467+ // coordinator at one queued plus one in-flight capture, while the dirty
468+ // generation ensures a later checkpoint observes mutations made meanwhile.
469+ if sess.snapshotQuarantined || !sess.snapDirty.Load() || sess.snapshotPending ||
470+ (!final && routineRateLimited && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)) {
471+ available := !sess.snapshotQuarantined
399472 sess.snapshotMu.Unlock()
400- return true
473+ return available
474+ }
475+ if sess.snapshotPublicationContext == nil {
476+ sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())
401477 }
402478 generation := sess.snapshotGeneration
479+ publicationContext := sess.snapshotPublicationContext
403480 sess.snapshotCapturedGeneration = generation
404481 sess.snapshotAttempted = true
405482 sess.snapshotAttemptKind = kind
@@ -411,11 +488,22 @@ func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bo
411488 capture, ok := d.captureSnapshotState(sess, generation)
412489 if capture != nil {
413490 capture.attemptKind = kind
491+ capture.publicationContext = publicationContext
414492 }
415493 if !ok {
416494 d.finishSnapshotCapture(capture, false)
417495 return false
418496 }
497+ sess.snapshotMu.Lock()
498+ quarantined := sess.snapshotQuarantined
499+ if !quarantined {
500+ sess.snapshotQueuedCapture = capture
501+ }
502+ sess.snapshotMu.Unlock()
503+ if quarantined {
504+ d.finishSnapshotCapture(capture, false)
505+ return false
506+ }
419507 if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) {
420508 return true
421509 }
@@ -586,6 +674,28 @@ func (d *Daemon) encodeSnapshotCapture(capture *snapshotCapture) ([]byte, error)
586674 return d.snapshotMarshal(snap)
587675 }
588676
677+// startSnapshotPublication moves a retained capture from the producer queue to
678+// the repository worker. Quarantine and this transition share snapshotMu, so a
679+// repository call cannot begin after destructive teardown has quarantined the
680+// session.
681+func startSnapshotPublication(capture *snapshotCapture) bool {
682+ if capture == nil || capture.session == nil {
683+ return false
684+ }
685+ sess := capture.session
686+ sess.snapshotMu.Lock()
687+ defer sess.snapshotMu.Unlock()
688+ if sess.snapshotQuarantined || sess.snapshotQueuedCapture != capture {
689+ return false
690+ }
691+ sess.snapshotQueuedCapture = nil
692+ sess.snapshotInFlightCapture = capture
693+ sess.snapshotPublicationDone = make(chan struct{})
694+ sess.snapshotPending = true
695+ sess.signalSnapshotChangedLocked()
696+ return true
697+}
698+
589699 func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) {
590700 if capture == nil {
591701 return
@@ -595,8 +705,19 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool)
595705 if capture.session.snapshotPendingCaptures > 0 {
596706 capture.session.snapshotPendingCaptures--
597707 }
598- capture.session.snapshotPending = capture.session.snapshotPendingCaptures > 0
599- if capture.attemptKind == snapshotAttemptRoutine && d.snapshotRepository != nil {
708+ if capture.session.snapshotQueuedCapture == capture {
709+ capture.session.snapshotQueuedCapture = nil
710+ }
711+ if capture.session.snapshotInFlightCapture == capture {
712+ capture.session.snapshotInFlightCapture = nil
713+ if capture.session.snapshotPublicationDone != nil {
714+ close(capture.session.snapshotPublicationDone)
715+ capture.session.snapshotPublicationDone = nil
716+ }
717+ }
718+ capture.session.snapshotPending = capture.session.snapshotPendingCaptures > 0 ||
719+ capture.session.snapshotQueuedCapture != nil || capture.session.snapshotInFlightCapture != nil
720+ if !capture.coordinatorDiscarded && capture.attemptKind == snapshotAttemptRoutine && d.snapshotRepository != nil {
600721 // The interval is measured from completion, including a failed
601722 // attempt. A forced teardown checkpoint deliberately leaves it alone.
602723 capture.session.snapshotNextEligibleAt = d.clock.Now().Add(snapshotInterval)
@@ -695,16 +816,22 @@ func (d *Daemon) startSnapshotEncodeWorker() {
695816 go func() {
696817 defer close(done)
697818 write := func(capture *snapshotCapture) bool {
698- if workerCtx.Err() != nil || !d.setSnapshotWorkerInFlight(workerID, capture) {
819+ if workerCtx.Err() != nil || !startSnapshotPublication(capture) || !d.setSnapshotWorkerInFlight(workerID, capture) {
699820 d.finishSnapshotCapture(capture, false)
700- return false
821+ return workerCtx.Err() == nil
701822 }
702823 publication, err := d.incrementalPublication(capture)
824+ publicationContext := capture.publicationContext
825+ if publicationContext == nil {
826+ publicationContext = workerCtx
827+ }
703828 if err == nil && workerCtx.Err() == nil {
704- err = d.snapshotRepository.Publish(workerCtx, publication)
829+ if err = publicationContext.Err(); err == nil {
830+ err = d.snapshotRepository.Publish(publicationContext, publication)
831+ }
705832 }
706833 d.clearSnapshotWorkerInFlight(workerID, capture)
707- if err != nil && workerCtx.Err() == nil {
834+ if err != nil && workerCtx.Err() == nil && publicationContext.Err() == nil {
708835 // Global, not session-scoped: by now the session may already be
709836 // torn down, so a session notice would be dead-on-arrival. No lock
710837 // is held here (clearSnapshotWorkerInFlight released its own).
@@ -34,14 +34,16 @@ const (
34 )34 )
35 35
36 type snapshotCapture struct {36 type snapshotCapture struct {
37- session *session37+ session *session
38- attemptKind snapshotAttemptKind38+ attemptKind snapshotAttemptKind
39- generation uint6439+ generation uint64
40- name string40+ name string
41- createdAt uint6441+ createdAt uint64
42- active uint1642+ active uint16
43- tabs []snapshotCaptureTab43+ tabs []snapshotCaptureTab
44- finishOnce sync.Once44+ publicationContext context.Context
45+ coordinatorDiscarded bool // guarded by session.snapshotMu
46+ finishOnce sync.Once
45 }47 }
46 48
47 type snapshotCaptureTab struct {49 type snapshotCaptureTab struct {
@@ -299,6 +301,72 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
299 return nil301 return nil
300 }302 }
301 303
304+// snapshotCoordinatorContext returns the cancellation context shared by this
305+// session's queued and in-flight captures. snapshotMu serializes creation with
306+// quarantine, so a publisher can never obtain a live context after quarantine.
307+func snapshotCoordinatorContext(sess *session) (context.Context, context.CancelFunc) {
308+ sess.snapshotMu.Lock()
309+ defer sess.snapshotMu.Unlock()
310+ if sess.snapshotPublicationContext == nil {
311+ sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())
312+ }
313+ return sess.snapshotPublicationContext, sess.snapshotPublicationCancel
314+}
315+
316+// quarantineSnapshotCoordinator prevents a deleted session from starting any
317+// further publication. The returned join channel is closed after an already
318+// started repository call returns; callers must wait without daemon or session
319+// locks before deleting repository state.
320+func quarantineSnapshotCoordinator(sess *session) <-chan struct{} {
321+ if sess == nil {
322+ done := make(chan struct{})
323+ close(done)
324+ return done
325+ }
326+ sess.snapshotMu.Lock()
327+ sess.snapshotQuarantined = true
328+ sess.snapEligible.Store(false)
329+ if sess.snapshotPublicationCancel != nil {
330+ sess.snapshotPublicationCancel()
331+ }
332+ // Remove a not-yet-started capture from the coordinator immediately. The
333+ // global queue may still contain it, but startSnapshotPublication compares
334+ // identity and will discard it even if this session is later renamed.
335+ if sess.snapshotQueuedCapture != nil {
336+ sess.snapshotQueuedCapture.coordinatorDiscarded = true
337+ sess.snapshotQueuedCapture = nil
338+ if sess.snapshotPendingCaptures > 0 {
339+ sess.snapshotPendingCaptures--
340+ }
341+ sess.snapshotPending = sess.snapshotPendingCaptures > 0 || sess.snapshotInFlightCapture != nil
342+ }
343+ done := sess.snapshotPublicationDone
344+ if done == nil {
345+ done = make(chan struct{})
346+ close(done)
347+ }
348+ sess.signalSnapshotChangedLocked()
349+ sess.snapshotMu.Unlock()
350+ return done
351+}
352+
353+// resumeSnapshotCoordinator is used only after a rename's old repository name
354+// has been quarantined and deleted. Queued captures were discarded during
355+// quarantine, so the new context cannot publish under the old identity.
356+func resumeSnapshotCoordinator(sess *session) {
357+ if sess == nil {
358+ return
359+ }
360+ sess.snapshotMu.Lock()
361+ if sess.snapshotPublicationCancel != nil {
362+ sess.snapshotPublicationCancel()
363+ }
364+ sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())
365+ sess.snapshotQuarantined = false
366+ sess.signalSnapshotChangedLocked()
367+ sess.snapshotMu.Unlock()
368+}
369+
302 func markSnapshotDirty(sess *session) {370 func markSnapshotDirty(sess *session) {
303 if sess == nil || !sess.snapEligible.Load() {371 if sess == nil || !sess.snapEligible.Load() {
304 return372 return
@@ -395,11 +463,20 @@ func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bo
395 }463 }
396 sess.snapshotMu.Lock()464 sess.snapshotMu.Lock()
397 routineRateLimited := d.snapshotRepository != nil465 routineRateLimited := d.snapshotRepository != nil
398- if !sess.snapDirty.Load() || (!final && (sess.snapshotPending || (routineRateLimited && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)))) {466+ // Forced attempts coalesce with a routine capture too. This keeps the
467+ // coordinator at one queued plus one in-flight capture, while the dirty
468+ // generation ensures a later checkpoint observes mutations made meanwhile.
469+ if sess.snapshotQuarantined || !sess.snapDirty.Load() || sess.snapshotPending ||
470+ (!final && routineRateLimited && !sess.snapshotNextEligibleAt.IsZero() && d.clock.Now().Before(sess.snapshotNextEligibleAt)) {
471+ available := !sess.snapshotQuarantined
399 sess.snapshotMu.Unlock()472 sess.snapshotMu.Unlock()
400- return true473+ return available
474+ }
475+ if sess.snapshotPublicationContext == nil {
476+ sess.snapshotPublicationContext, sess.snapshotPublicationCancel = context.WithCancel(context.Background())
401 }477 }
402 generation := sess.snapshotGeneration478 generation := sess.snapshotGeneration
479+ publicationContext := sess.snapshotPublicationContext
403 sess.snapshotCapturedGeneration = generation480 sess.snapshotCapturedGeneration = generation
404 sess.snapshotAttempted = true481 sess.snapshotAttempted = true
405 sess.snapshotAttemptKind = kind482 sess.snapshotAttemptKind = kind
@@ -411,11 +488,22 @@ func (d *Daemon) scheduleSnapshotWithFinalFallback(sess *session, final bool) bo
411 capture, ok := d.captureSnapshotState(sess, generation)488 capture, ok := d.captureSnapshotState(sess, generation)
412 if capture != nil {489 if capture != nil {
413 capture.attemptKind = kind490 capture.attemptKind = kind
491+ capture.publicationContext = publicationContext
414 }492 }
415 if !ok {493 if !ok {
416 d.finishSnapshotCapture(capture, false)494 d.finishSnapshotCapture(capture, false)
417 return false495 return false
418 }496 }
497+ sess.snapshotMu.Lock()
498+ quarantined := sess.snapshotQuarantined
499+ if !quarantined {
500+ sess.snapshotQueuedCapture = capture
501+ }
502+ sess.snapshotMu.Unlock()
503+ if quarantined {
504+ d.finishSnapshotCapture(capture, false)
505+ return false
506+ }
419 if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) {507 if d.enqueueSnapshotCapture(capture) || (final && d.enqueueFinalSnapshotCapture(capture)) {
420 return true508 return true
421 }509 }
@@ -586,6 +674,28 @@ func (d *Daemon) encodeSnapshotCapture(capture *snapshotCapture) ([]byte, error)
586 return d.snapshotMarshal(snap)674 return d.snapshotMarshal(snap)
587 }675 }
588 676
677+// startSnapshotPublication moves a retained capture from the producer queue to
678+// the repository worker. Quarantine and this transition share snapshotMu, so a
679+// repository call cannot begin after destructive teardown has quarantined the
680+// session.
681+func startSnapshotPublication(capture *snapshotCapture) bool {
682+ if capture == nil || capture.session == nil {
683+ return false
684+ }
685+ sess := capture.session
686+ sess.snapshotMu.Lock()
687+ defer sess.snapshotMu.Unlock()
688+ if sess.snapshotQuarantined || sess.snapshotQueuedCapture != capture {
689+ return false
690+ }
691+ sess.snapshotQueuedCapture = nil
692+ sess.snapshotInFlightCapture = capture
693+ sess.snapshotPublicationDone = make(chan struct{})
694+ sess.snapshotPending = true
695+ sess.signalSnapshotChangedLocked()
696+ return true
697+}
698+
589 func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) {699 func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool) {
590 if capture == nil {700 if capture == nil {
591 return701 return
@@ -595,8 +705,19 @@ func (d *Daemon) finishSnapshotCapture(capture *snapshotCapture, succeeded bool)
595 if capture.session.snapshotPendingCaptures > 0 {705 if capture.session.snapshotPendingCaptures > 0 {
596 capture.session.snapshotPendingCaptures--706 capture.session.snapshotPendingCaptures--
597 }707 }
598- capture.session.snapshotPending = capture.session.snapshotPendingCaptures > 0708+ if capture.session.snapshotQueuedCapture == capture {
599- if capture.attemptKind == snapshotAttemptRoutine && d.snapshotRepository != nil {709+ capture.session.snapshotQueuedCapture = nil
710+ }
711+ if capture.session.snapshotInFlightCapture == capture {
712+ capture.session.snapshotInFlightCapture = nil
713+ if capture.session.snapshotPublicationDone != nil {
714+ close(capture.session.snapshotPublicationDone)
715+ capture.session.snapshotPublicationDone = nil
716+ }
717+ }
718+ capture.session.snapshotPending = capture.session.snapshotPendingCaptures > 0 ||
719+ capture.session.snapshotQueuedCapture != nil || capture.session.snapshotInFlightCapture != nil
720+ if !capture.coordinatorDiscarded && capture.attemptKind == snapshotAttemptRoutine && d.snapshotRepository != nil {
600 // The interval is measured from completion, including a failed721 // The interval is measured from completion, including a failed
601 // attempt. A forced teardown checkpoint deliberately leaves it alone.722 // attempt. A forced teardown checkpoint deliberately leaves it alone.
602 capture.session.snapshotNextEligibleAt = d.clock.Now().Add(snapshotInterval)723 capture.session.snapshotNextEligibleAt = d.clock.Now().Add(snapshotInterval)
@@ -695,16 +816,22 @@ func (d *Daemon) startSnapshotEncodeWorker() {
695 go func() {816 go func() {
696 defer close(done)817 defer close(done)
697 write := func(capture *snapshotCapture) bool {818 write := func(capture *snapshotCapture) bool {
698- if workerCtx.Err() != nil || !d.setSnapshotWorkerInFlight(workerID, capture) {819+ if workerCtx.Err() != nil || !startSnapshotPublication(capture) || !d.setSnapshotWorkerInFlight(workerID, capture) {
699 d.finishSnapshotCapture(capture, false)820 d.finishSnapshotCapture(capture, false)
700- return false821+ return workerCtx.Err() == nil
701 }822 }
702 publication, err := d.incrementalPublication(capture)823 publication, err := d.incrementalPublication(capture)
824+ publicationContext := capture.publicationContext
825+ if publicationContext == nil {
826+ publicationContext = workerCtx
827+ }
703 if err == nil && workerCtx.Err() == nil {828 if err == nil && workerCtx.Err() == nil {
704- err = d.snapshotRepository.Publish(workerCtx, publication)829+ if err = publicationContext.Err(); err == nil {
830+ err = d.snapshotRepository.Publish(publicationContext, publication)
831+ }
705 }832 }
706 d.clearSnapshotWorkerInFlight(workerID, capture)833 d.clearSnapshotWorkerInFlight(workerID, capture)
707- if err != nil && workerCtx.Err() == nil {834+ if err != nil && workerCtx.Err() == nil && publicationContext.Err() == nil {
708 // Global, not session-scoped: by now the session may already be835 // Global, not session-scoped: by now the session may already be
709 // torn down, so a session notice would be dead-on-arrival. No lock836 // torn down, so a session notice would be dead-on-arrival. No lock
710 // is held here (clearSnapshotWorkerInFlight released its own).837 // is held here (clearSnapshotWorkerInFlight released its own).
M internal/usecase/daemon/snapshot_scheduler_test.go +54 -0
@@ -14,6 +14,60 @@ import (
1414 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1515 )
1616
17+func TestSnapshotCoordinatorQuarantineCancelsPublicationBeforeDelete(t *testing.T) {
18+ sess := newSnapshotTestSession(t, "work", false, "/work")
19+ ctx, cancel := snapshotCoordinatorContext(sess)
20+ done := quarantineSnapshotCoordinator(sess)
21+
22+ select {
23+ case <-ctx.Done():
24+ case <-time.After(testWaitTimeout):
25+ t.Fatal("quarantine did not cancel publication")
26+ }
27+ select {
28+ case <-done:
29+ case <-time.After(testWaitTimeout):
30+ t.Fatal("quarantine did not join idle coordinator")
31+ }
32+ cancel()
33+}
34+
35+func TestSnapshotCoordinatorQuarantineJoinsInFlightPublication(t *testing.T) {
36+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
37+ repository := portsmocks.NewMockSnapshotRepository(t)
38+ WithSnapshotRepository(repository, nil)(d)
39+ startSnapshotEncodeWorker(t, d)
40+
41+ started := make(chan struct{})
42+ release := make(chan struct{})
43+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.SnapshotPublication) error {
44+ close(started)
45+ <-release // deliberately ignore cancellation
46+ return nil
47+ }).Once()
48+ sess := newSnapshotTestSession(t, "work", false, "/work")
49+ markSnapshotDirty(sess)
50+ require.True(t, d.scheduleSnapshot(sess))
51+ select {
52+ case <-started:
53+ case <-time.After(testWaitTimeout):
54+ t.Fatal("publication did not start")
55+ }
56+
57+ done := quarantineSnapshotCoordinator(sess)
58+ select {
59+ case <-done:
60+ t.Fatal("quarantine joined before publication returned")
61+ default:
62+ }
63+ close(release)
64+ select {
65+ case <-done:
66+ case <-time.After(testWaitTimeout):
67+ t.Fatal("quarantine did not join publication")
68+ }
69+}
70+
1771 func TestRoutineSnapshotEligibilityStartsAtCompletionAndForcedDoesNotMoveIt(t *testing.T) {
1872 clock := &snapshotSchedulerClock{now: time.Unix(100, 0)}
1973 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
@@ -14,6 +14,60 @@ import (
14 portsmocks "github.com/bnema/vev/internal/ports/mocks"14 portsmocks "github.com/bnema/vev/internal/ports/mocks"
15 )15 )
16 16
17+func TestSnapshotCoordinatorQuarantineCancelsPublicationBeforeDelete(t *testing.T) {
18+ sess := newSnapshotTestSession(t, "work", false, "/work")
19+ ctx, cancel := snapshotCoordinatorContext(sess)
20+ done := quarantineSnapshotCoordinator(sess)
21+
22+ select {
23+ case <-ctx.Done():
24+ case <-time.After(testWaitTimeout):
25+ t.Fatal("quarantine did not cancel publication")
26+ }
27+ select {
28+ case <-done:
29+ case <-time.After(testWaitTimeout):
30+ t.Fatal("quarantine did not join idle coordinator")
31+ }
32+ cancel()
33+}
34+
35+func TestSnapshotCoordinatorQuarantineJoinsInFlightPublication(t *testing.T) {
36+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
37+ repository := portsmocks.NewMockSnapshotRepository(t)
38+ WithSnapshotRepository(repository, nil)(d)
39+ startSnapshotEncodeWorker(t, d)
40+
41+ started := make(chan struct{})
42+ release := make(chan struct{})
43+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.SnapshotPublication) error {
44+ close(started)
45+ <-release // deliberately ignore cancellation
46+ return nil
47+ }).Once()
48+ sess := newSnapshotTestSession(t, "work", false, "/work")
49+ markSnapshotDirty(sess)
50+ require.True(t, d.scheduleSnapshot(sess))
51+ select {
52+ case <-started:
53+ case <-time.After(testWaitTimeout):
54+ t.Fatal("publication did not start")
55+ }
56+
57+ done := quarantineSnapshotCoordinator(sess)
58+ select {
59+ case <-done:
60+ t.Fatal("quarantine joined before publication returned")
61+ default:
62+ }
63+ close(release)
64+ select {
65+ case <-done:
66+ case <-time.After(testWaitTimeout):
67+ t.Fatal("quarantine did not join publication")
68+ }
69+}
70+
17 func TestRoutineSnapshotEligibilityStartsAtCompletionAndForcedDoesNotMoveIt(t *testing.T) {71 func TestRoutineSnapshotEligibilityStartsAtCompletionAndForcedDoesNotMoveIt(t *testing.T) {
18 clock := &snapshotSchedulerClock{now: time.Unix(100, 0)}72 clock := &snapshotSchedulerClock{now: time.Unix(100, 0)}
19 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)73 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)

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