detached 98b1868

test(daemon): cover competing shutdown paths

commit
98b1868b4ad192c329f238c93ab6ff8f90786fad
author
brice · · 2026-07-24 16:41 UTC
parent
f00a00d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +51 -35
M internal/usecase/daemon/snapshot_scheduler_test.go +51 -35
@@ -442,64 +442,80 @@ func TestForcedSnapshotShutdownTimeoutRetainsRetryableStateAndNotice(t *testing.
442442 }
443443 }
444444
445-func TestShutdownDeadlineBoundsWaitForExistingTeardownOwner(t *testing.T) {
446- clock := newSnapshotDeadlineClock()
447- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)
445+func TestServeShutdownDeadlineBoundsPaneExitTeardownOwner(t *testing.T) {
446+ pty, releasePTY := newBlockingPTY(t)
447+ clock := newServeShutdownClock()
448+ d := newTestDaemon(t, newFactory(t, pty), clock)
448449 repository := portsmocks.NewMockSnapshotRepository(t)
449450 WithSnapshotRepository(repository, nil)(d)
450- startSnapshotEncodeWorker(t, d)
451+ repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
451452
452453 publicationStarted := make(chan struct{})
453454 releasePublication := make(chan struct{})
454- var releaseOnce sync.Once
455- release := func() { releaseOnce.Do(func() { close(releasePublication) }) }
456- t.Cleanup(release)
457- repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ ports.SnapshotPublication) error {
455+ var releasePublicationOnce sync.Once
456+ releaseSnapshot := func() { releasePublicationOnce.Do(func() { close(releasePublication) }) }
457+ t.Cleanup(releaseSnapshot)
458+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
459+ require.Equal(t, uint64(1), publication.Generation)
458460 close(publicationStarted)
459461 <-releasePublication
460462 return nil
461463 }).Once()
462464
463- sess := newSnapshotTestSession(t, "work", false, "/work")
464- pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
465- require.True(t, ok)
466- pty.EXPECT().Close().Return(nil).Maybe()
467- d.sessions[sess.id] = sess
465+ tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
466+ listener := serveSnapshotListener(t, tr)
467+ ctx, cancel := context.WithCancel(context.Background())
468+ defer cancel()
469+ served := make(chan error, 1)
470+ go func() { served <- d.Serve(ctx, listener) }()
471+
472+ awaitFrame(t, sends, ports.MsgWelcome)
473+ sess := firstSession(d)
474+ require.NotNil(t, sess)
468475
469- ordinaryDone := make(chan error, 1)
470- go func() { ordinaryDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
476+ // Releasing the real pane reader drives EOF through ptyReader, reapPane, and
477+ // closePane. That ordinary lifecycle path must own teardown before Serve's
478+ // context cancellation enters its competing shutdownAll path.
479+ releasePTY()
471480 select {
472481 case <-publicationStarted:
473482 case <-time.After(testWaitTimeout):
474- t.Fatal("ordinary teardown did not become owner")
483+ t.Fatal("pane EOF teardown did not publish its terminal snapshot")
475484 }
485+ // The pane-exit owner has an independent final-snapshot timer. Keep it
486+ // unfired so Serve must use, and remain bounded by, its shared deadline.
487+ _ = clock.nextFinalTimer(t)
476488
477- // The ordinary teardown owns its own final-snapshot deadline. Leave it
478- // unfired so it remains distinct from Serve's shared shutdown budget.
479- _ = clock.nextTimer(t)
480- deadline := newSnapshotShutdownDeadline(clock)
481- defer deadline.stop()
482- shutdownDeadlineTimer := clock.nextTimer(t)
483- shutdownDone := make(chan error, 1)
484- go func() {
485- shutdownDone <- d.killSessionWithSnapshotDeadline(sess, ports.ReasonServerShutdown, false, deadline)
486- }()
489+ cancel()
490+ shutdownDeadlineTimer := clock.nextFinalTimer(t)
487491 awaitTeardownWaiters(t, sess, 1)
488492 shutdownDeadlineTimer.fire()
493+ awaitTeardownWaiters(t, sess, 0)
489494
490- select {
491- case err := <-shutdownDone:
492- require.NoError(t, err)
493- case <-time.After(testWaitTimeout):
494- t.Fatal("daemon shutdown waited beyond its shared deadline for the ordinary teardown owner")
495- }
495+ sess.snapshotMu.Lock()
496+ generation := sess.snapshotGeneration
497+ sess.snapshotMu.Unlock()
498+ require.Equal(t, uint64(1), generation, "Serve shutdown must not create a competing terminal generation")
499+ require.Equal(t, int64(snapshotFinalFlushTimeout), clock.finalBudget.Load(), "Serve must spend only its shared snapshot deadline")
496500
497- release()
501+ // Let the ordinary owner commit removal, then join the complete Serve and
502+ // snapshot-worker lifecycles so no reader, teardown, or worker is leaked.
503+ releaseSnapshot()
498504 select {
499- case err := <-ordinaryDone:
505+ case err := <-served:
500506 require.NoError(t, err)
501507 case <-time.After(testWaitTimeout):
502- t.Fatal("ordinary teardown did not finish after its publication was released")
508+ t.Fatal("Serve did not return after the pane-exit owner completed")
509+ }
510+ d.snapshotWorkerMu.Lock()
511+ workerDone := d.snapshotWorkerDone
512+ d.snapshotWorkerMu.Unlock()
513+ if workerDone != nil {
514+ select {
515+ case <-workerDone:
516+ case <-time.After(testWaitTimeout):
517+ t.Fatal("snapshot worker leaked after Serve returned")
518+ }
503519 }
504520 }
505521
@@ -442,64 +442,80 @@ func TestForcedSnapshotShutdownTimeoutRetainsRetryableStateAndNotice(t *testing.
442 }442 }
443 }443 }
444 444
445-func TestShutdownDeadlineBoundsWaitForExistingTeardownOwner(t *testing.T) {445+func TestServeShutdownDeadlineBoundsPaneExitTeardownOwner(t *testing.T) {
446- clock := newSnapshotDeadlineClock()446+ pty, releasePTY := newBlockingPTY(t)
447- d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), clock)447+ clock := newServeShutdownClock()
448+ d := newTestDaemon(t, newFactory(t, pty), clock)
448 repository := portsmocks.NewMockSnapshotRepository(t)449 repository := portsmocks.NewMockSnapshotRepository(t)
449 WithSnapshotRepository(repository, nil)(d)450 WithSnapshotRepository(repository, nil)(d)
450- startSnapshotEncodeWorker(t, d)451+ repository.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
451 452
452 publicationStarted := make(chan struct{})453 publicationStarted := make(chan struct{})
453 releasePublication := make(chan struct{})454 releasePublication := make(chan struct{})
454- var releaseOnce sync.Once455+ var releasePublicationOnce sync.Once
455- release := func() { releaseOnce.Do(func() { close(releasePublication) }) }456+ releaseSnapshot := func() { releasePublicationOnce.Do(func() { close(releasePublication) }) }
456- t.Cleanup(release)457+ t.Cleanup(releaseSnapshot)
457- repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ ports.SnapshotPublication) error {458+ repository.EXPECT().Publish(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, publication ports.SnapshotPublication) error {
459+ require.Equal(t, uint64(1), publication.Generation)
458 close(publicationStarted)460 close(publicationStarted)
459 <-releasePublication461 <-releasePublication
460 return nil462 return nil
461 }).Once()463 }).Once()
462 464
463- sess := newSnapshotTestSession(t, "work", false, "/work")465+ tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
464- pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)466+ listener := serveSnapshotListener(t, tr)
465- require.True(t, ok)467+ ctx, cancel := context.WithCancel(context.Background())
466- pty.EXPECT().Close().Return(nil).Maybe()468+ defer cancel()
467- d.sessions[sess.id] = sess469+ served := make(chan error, 1)
470+ go func() { served <- d.Serve(ctx, listener) }()
471+
472+ awaitFrame(t, sends, ports.MsgWelcome)
473+ sess := firstSession(d)
474+ require.NotNil(t, sess)
468 475
469- ordinaryDone := make(chan error, 1)476+ // Releasing the real pane reader drives EOF through ptyReader, reapPane, and
470- go func() { ordinaryDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }()477+ // closePane. That ordinary lifecycle path must own teardown before Serve's
478+ // context cancellation enters its competing shutdownAll path.
479+ releasePTY()
471 select {480 select {
472 case <-publicationStarted:481 case <-publicationStarted:
473 case <-time.After(testWaitTimeout):482 case <-time.After(testWaitTimeout):
474- t.Fatal("ordinary teardown did not become owner")483+ t.Fatal("pane EOF teardown did not publish its terminal snapshot")
475 }484 }
485+ // The pane-exit owner has an independent final-snapshot timer. Keep it
486+ // unfired so Serve must use, and remain bounded by, its shared deadline.
487+ _ = clock.nextFinalTimer(t)
476 488
477- // The ordinary teardown owns its own final-snapshot deadline. Leave it489+ cancel()
478- // unfired so it remains distinct from Serve's shared shutdown budget.490+ shutdownDeadlineTimer := clock.nextFinalTimer(t)
479- _ = clock.nextTimer(t)
480- deadline := newSnapshotShutdownDeadline(clock)
481- defer deadline.stop()
482- shutdownDeadlineTimer := clock.nextTimer(t)
483- shutdownDone := make(chan error, 1)
484- go func() {
485- shutdownDone <- d.killSessionWithSnapshotDeadline(sess, ports.ReasonServerShutdown, false, deadline)
486- }()
487 awaitTeardownWaiters(t, sess, 1)491 awaitTeardownWaiters(t, sess, 1)
488 shutdownDeadlineTimer.fire()492 shutdownDeadlineTimer.fire()
493+ awaitTeardownWaiters(t, sess, 0)
489 494
490- select {495+ sess.snapshotMu.Lock()
491- case err := <-shutdownDone:496+ generation := sess.snapshotGeneration
492- require.NoError(t, err)497+ sess.snapshotMu.Unlock()
493- case <-time.After(testWaitTimeout):498+ require.Equal(t, uint64(1), generation, "Serve shutdown must not create a competing terminal generation")
494- t.Fatal("daemon shutdown waited beyond its shared deadline for the ordinary teardown owner")499+ require.Equal(t, int64(snapshotFinalFlushTimeout), clock.finalBudget.Load(), "Serve must spend only its shared snapshot deadline")
495- }
496 500
497- release()501+ // Let the ordinary owner commit removal, then join the complete Serve and
502+ // snapshot-worker lifecycles so no reader, teardown, or worker is leaked.
503+ releaseSnapshot()
498 select {504 select {
499- case err := <-ordinaryDone:505+ case err := <-served:
500 require.NoError(t, err)506 require.NoError(t, err)
501 case <-time.After(testWaitTimeout):507 case <-time.After(testWaitTimeout):
502- t.Fatal("ordinary teardown did not finish after its publication was released")508+ t.Fatal("Serve did not return after the pane-exit owner completed")
509+ }
510+ d.snapshotWorkerMu.Lock()
511+ workerDone := d.snapshotWorkerDone
512+ d.snapshotWorkerMu.Unlock()
513+ if workerDone != nil {
514+ select {
515+ case <-workerDone:
516+ case <-time.After(testWaitTimeout):
517+ t.Fatal("snapshot worker leaked after Serve returned")
518+ }
503 }519 }
504 }520 }
505 521

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