detached 4eb20a0

test(daemon): make coordinator lifecycle red deterministic

commit
4eb20a0cb9d60b292e6b936da780589373be9cb9
author
brice · · 2026-07-11 14:59 UTC
parent
7a79ec3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +77 -39
M internal/usecase/daemon/render_coordinator_test.go +77 -39
@@ -61,13 +61,16 @@ func (h *coordinatorHarness) armedTimers(t *testing.T) []*signalTimer {
6161 }
6262 }
6363
64+// Coordinator calls and fake-clock advancement are synchronous test steps.
65+// These helpers deliberately never wait on wall time: a missing event is a
66+// deterministic contract failure, not a slow behavior to poll for.
6467 func awaitWake(t *testing.T, ch chan renderWake) renderWake {
6568 t.Helper()
6669 select {
6770 case w := <-ch:
6871 return w
69- case <-time.After(2 * time.Second):
70- t.Fatal("timed out waiting for a coordinator wake")
72+ default:
73+ t.Fatal("coordinator did not synchronously publish a wake after fake-clock advancement")
7174 return renderWake{}
7275 }
7376 }
@@ -77,7 +80,7 @@ func requireNoWake(t *testing.T, ch chan renderWake) {
7780 select {
7881 case w := <-ch:
7982 t.Fatalf("unexpected coordinator wake: %+v", w)
80- case <-time.After(50 * time.Millisecond):
83+ default:
8184 }
8285 }
8386
@@ -86,8 +89,8 @@ func awaitInvalidation(t *testing.T, ch chan renderInvalidation) renderInvalidat
8689 select {
8790 case inv := <-ch:
8891 return inv
89- case <-time.After(2 * time.Second):
90- t.Fatal("timed out waiting for a coordinator invalidation")
92+ default:
93+ t.Fatal("producer did not synchronously publish a coordinator invalidation")
9194 return renderInvalidation{}
9295 }
9396 }
@@ -97,7 +100,7 @@ func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) {
97100 select {
98101 case inv := <-ch:
99102 t.Fatalf("unexpected coordinator invalidation: %+v", inv)
100- case <-time.After(50 * time.Millisecond):
103+ default:
101104 }
102105 }
103106
@@ -106,12 +109,21 @@ func awaitScheduledTimer(t *testing.T, clk *signalClock) *signalTimer {
106109 select {
107110 case tm := <-clk.timers:
108111 return tm
109- case <-time.After(2 * time.Second):
110- t.Fatal("timed out waiting for a scheduled timer")
112+ default:
113+ t.Fatal("coordinator did not synchronously arm a fake-clock timer")
111114 return nil
112115 }
113116 }
114117
118+func requireNoCoordinatorOutputFrame(t *testing.T, sends chan ports.Frame) {
119+ t.Helper()
120+ select {
121+ case frame := <-sends:
122+ t.Fatalf("unexpected output frame: %+v", frame)
123+ default:
124+ }
125+}
126+
115127 // --- wake coalescing and deadlines --------------------------------------------
116128
117129 func TestRenderCoordinatorCoalescesWakesUnderOneDeadline(t *testing.T) {
@@ -387,32 +399,58 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
387399 },
388400 },
389401 {
390- name: "a displaced attachment cannot overwrite current metadata",
402+ name: "stale resize callbacks preserve latest metadata through lifecycle transitions",
391403 run: func(t *testing.T, rc *renderCoordinator, owner, replacement *attachedClient) {
392- require.Equal(t, uint64(1), rc.recordResizeRequest(sz(120, 40), owner))
393- rc.noteReplace(owner, replacement)
394- rc.attach(replacement)
395-
396- require.Zero(t, rc.recordResizeRequest(sz(80, 20), owner),
397- "a stale attachment must not advance the resize epoch")
398- snap := rc.resizeSnapshot()
399- require.Equal(t, sz(120, 40), snap.size)
400- require.Equal(t, uint64(1), snap.epoch)
401-
402- require.Equal(t, uint64(2), rc.recordResizeRequest(sz(100, 50), replacement))
403- snap = rc.resizeSnapshot()
404- require.Equal(t, sz(100, 50), snap.size)
405- require.Same(t, replacement, snap.source)
406- },
407- },
408- {
409- name: "detach cannot erase committed metadata",
410- run: func(t *testing.T, rc *renderCoordinator, owner, _ *attachedClient) {
411- require.Equal(t, uint64(1), rc.recordResizeRequest(sz(120, 40), owner))
412- rc.noteDetach(owner)
413- snap := rc.resizeSnapshot()
414- require.Equal(t, sz(120, 40), snap.size)
415- require.Equal(t, uint64(1), snap.epoch)
404+ type lifecycleCase struct {
405+ name string
406+ installNew bool
407+ }
408+ for _, tc := range []lifecycleCase{
409+ {name: "detach"},
410+ {name: "park"},
411+ {name: "session teardown"},
412+ {name: "replacement", installNew: true},
413+ } {
414+ t.Run(tc.name, func(t *testing.T) {
415+ h := newCoordinatorHarness(t)
416+ staleOwner := &attachedClient{}
417+ freshOwner := &attachedClient{}
418+ h.rc.attach(staleOwner)
419+ require.Equal(t, uint64(1), h.rc.recordResizeRequest(sz(120, 40), staleOwner))
420+
421+ // This models a retained PR #71 callback which captured the old
422+ // attachment before lifecycle ownership changed.
423+ staleCallback := func() uint64 {
424+ return h.rc.recordResizeRequest(sz(80, 20), staleOwner)
425+ }
426+
427+ switch tc.name {
428+ case "detach":
429+ h.rc.noteDetach(staleOwner)
430+ case "park":
431+ h.rc.notePark(staleOwner)
432+ case "session teardown":
433+ h.rc.noteSessionTeardown()
434+ case "replacement":
435+ h.rc.noteReplace(staleOwner, freshOwner)
436+ h.rc.attach(freshOwner)
437+ require.Equal(t, uint64(2), h.rc.recordResizeRequest(sz(100, 50), freshOwner))
438+ }
439+
440+ require.Zero(t, staleCallback(), "stale callback must not advance the resize epoch")
441+ snap := h.rc.resizeSnapshot()
442+ if tc.installNew {
443+ require.Equal(t, sz(100, 50), snap.size)
444+ require.Equal(t, uint64(2), snap.epoch)
445+ require.Same(t, freshOwner, snap.source)
446+ return
447+ }
448+ require.Equal(t, sz(120, 40), snap.size)
449+ require.Equal(t, uint64(1), snap.epoch)
450+ require.Same(t, staleOwner, snap.source,
451+ "the stale callback must not replace the recorded attachment identity")
452+ })
453+ }
416454 },
417455 },
418456 }
@@ -573,7 +611,7 @@ func TestProducerInvalidations(t *testing.T) {
573611
574612 awaitInvalidation(t, invs)
575613 requireNoInvalidation(t, invs)
576- requireNoOutputFrame(t, sends)
614+ requireNoCoordinatorOutputFrame(t, sends)
577615 })
578616 }
579617 }
@@ -626,13 +664,13 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
626664 "the coordinator must record the latest requested resize before delegating")
627665 require.Same(t, ac, snap.source)
628666 require.Equal(t, uint64(1), snap.epoch)
629- requireNoOutputFrame(t, sends)
667+ requireNoCoordinatorOutputFrame(t, sends)
630668
631669 timer.ch <- time.Time{}
632670 inv := awaitInvalidation(t, invs)
633671 require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")
634672 requireNoInvalidation(t, invs)
635- requireNoOutputFrame(t, sends)
673+ requireNoCoordinatorOutputFrame(t, sends)
636674 })
637675
638676 t.Run("stale generations stay rejected", func(t *testing.T) {
@@ -647,13 +685,13 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
647685
648686 first.ch <- time.Time{}
649687 requireNoInvalidation(t, invs)
650- requireNoOutputFrame(t, sends)
688+ requireNoCoordinatorOutputFrame(t, sends)
651689
652690 latest.ch <- time.Time{}
653691 inv := awaitInvalidation(t, invs)
654692 require.True(t, inv.reset)
655693 requireNoInvalidation(t, invs)
656- requireNoOutputFrame(t, sends)
694+ requireNoCoordinatorOutputFrame(t, sends)
657695 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, sess.renderCoordinator().resizeSnapshot().size)
658696 })
659697
@@ -666,6 +704,6 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
666704
667705 timer.ch <- time.Time{}
668706 requireNoInvalidation(t, invs)
669- requireNoOutputFrame(t, sends)
707+ requireNoCoordinatorOutputFrame(t, sends)
670708 })
671709 }
@@ -61,13 +61,16 @@ func (h *coordinatorHarness) armedTimers(t *testing.T) []*signalTimer {
61 }61 }
62 }62 }
63 63
64+// Coordinator calls and fake-clock advancement are synchronous test steps.
65+// These helpers deliberately never wait on wall time: a missing event is a
66+// deterministic contract failure, not a slow behavior to poll for.
64 func awaitWake(t *testing.T, ch chan renderWake) renderWake {67 func awaitWake(t *testing.T, ch chan renderWake) renderWake {
65 t.Helper()68 t.Helper()
66 select {69 select {
67 case w := <-ch:70 case w := <-ch:
68 return w71 return w
69- case <-time.After(2 * time.Second):72+ default:
70- t.Fatal("timed out waiting for a coordinator wake")73+ t.Fatal("coordinator did not synchronously publish a wake after fake-clock advancement")
71 return renderWake{}74 return renderWake{}
72 }75 }
73 }76 }
@@ -77,7 +80,7 @@ func requireNoWake(t *testing.T, ch chan renderWake) {
77 select {80 select {
78 case w := <-ch:81 case w := <-ch:
79 t.Fatalf("unexpected coordinator wake: %+v", w)82 t.Fatalf("unexpected coordinator wake: %+v", w)
80- case <-time.After(50 * time.Millisecond):83+ default:
81 }84 }
82 }85 }
83 86
@@ -86,8 +89,8 @@ func awaitInvalidation(t *testing.T, ch chan renderInvalidation) renderInvalidat
86 select {89 select {
87 case inv := <-ch:90 case inv := <-ch:
88 return inv91 return inv
89- case <-time.After(2 * time.Second):92+ default:
90- t.Fatal("timed out waiting for a coordinator invalidation")93+ t.Fatal("producer did not synchronously publish a coordinator invalidation")
91 return renderInvalidation{}94 return renderInvalidation{}
92 }95 }
93 }96 }
@@ -97,7 +100,7 @@ func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) {
97 select {100 select {
98 case inv := <-ch:101 case inv := <-ch:
99 t.Fatalf("unexpected coordinator invalidation: %+v", inv)102 t.Fatalf("unexpected coordinator invalidation: %+v", inv)
100- case <-time.After(50 * time.Millisecond):103+ default:
101 }104 }
102 }105 }
103 106
@@ -106,12 +109,21 @@ func awaitScheduledTimer(t *testing.T, clk *signalClock) *signalTimer {
106 select {109 select {
107 case tm := <-clk.timers:110 case tm := <-clk.timers:
108 return tm111 return tm
109- case <-time.After(2 * time.Second):112+ default:
110- t.Fatal("timed out waiting for a scheduled timer")113+ t.Fatal("coordinator did not synchronously arm a fake-clock timer")
111 return nil114 return nil
112 }115 }
113 }116 }
114 117
118+func requireNoCoordinatorOutputFrame(t *testing.T, sends chan ports.Frame) {
119+ t.Helper()
120+ select {
121+ case frame := <-sends:
122+ t.Fatalf("unexpected output frame: %+v", frame)
123+ default:
124+ }
125+}
126+
115 // --- wake coalescing and deadlines --------------------------------------------127 // --- wake coalescing and deadlines --------------------------------------------
116 128
117 func TestRenderCoordinatorCoalescesWakesUnderOneDeadline(t *testing.T) {129 func TestRenderCoordinatorCoalescesWakesUnderOneDeadline(t *testing.T) {
@@ -387,32 +399,58 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) {
387 },399 },
388 },400 },
389 {401 {
390- name: "a displaced attachment cannot overwrite current metadata",402+ name: "stale resize callbacks preserve latest metadata through lifecycle transitions",
391 run: func(t *testing.T, rc *renderCoordinator, owner, replacement *attachedClient) {403 run: func(t *testing.T, rc *renderCoordinator, owner, replacement *attachedClient) {
392- require.Equal(t, uint64(1), rc.recordResizeRequest(sz(120, 40), owner))404+ type lifecycleCase struct {
393- rc.noteReplace(owner, replacement)405+ name string
394- rc.attach(replacement)406+ installNew bool
395-407+ }
396- require.Zero(t, rc.recordResizeRequest(sz(80, 20), owner),408+ for _, tc := range []lifecycleCase{
397- "a stale attachment must not advance the resize epoch")409+ {name: "detach"},
398- snap := rc.resizeSnapshot()410+ {name: "park"},
399- require.Equal(t, sz(120, 40), snap.size)411+ {name: "session teardown"},
400- require.Equal(t, uint64(1), snap.epoch)412+ {name: "replacement", installNew: true},
401-413+ } {
402- require.Equal(t, uint64(2), rc.recordResizeRequest(sz(100, 50), replacement))414+ t.Run(tc.name, func(t *testing.T) {
403- snap = rc.resizeSnapshot()415+ h := newCoordinatorHarness(t)
404- require.Equal(t, sz(100, 50), snap.size)416+ staleOwner := &attachedClient{}
405- require.Same(t, replacement, snap.source)417+ freshOwner := &attachedClient{}
406- },418+ h.rc.attach(staleOwner)
407- },419+ require.Equal(t, uint64(1), h.rc.recordResizeRequest(sz(120, 40), staleOwner))
408- {420+
409- name: "detach cannot erase committed metadata",421+ // This models a retained PR #71 callback which captured the old
410- run: func(t *testing.T, rc *renderCoordinator, owner, _ *attachedClient) {422+ // attachment before lifecycle ownership changed.
411- require.Equal(t, uint64(1), rc.recordResizeRequest(sz(120, 40), owner))423+ staleCallback := func() uint64 {
412- rc.noteDetach(owner)424+ return h.rc.recordResizeRequest(sz(80, 20), staleOwner)
413- snap := rc.resizeSnapshot()425+ }
414- require.Equal(t, sz(120, 40), snap.size)426+
415- require.Equal(t, uint64(1), snap.epoch)427+ switch tc.name {
428+ case "detach":
429+ h.rc.noteDetach(staleOwner)
430+ case "park":
431+ h.rc.notePark(staleOwner)
432+ case "session teardown":
433+ h.rc.noteSessionTeardown()
434+ case "replacement":
435+ h.rc.noteReplace(staleOwner, freshOwner)
436+ h.rc.attach(freshOwner)
437+ require.Equal(t, uint64(2), h.rc.recordResizeRequest(sz(100, 50), freshOwner))
438+ }
439+
440+ require.Zero(t, staleCallback(), "stale callback must not advance the resize epoch")
441+ snap := h.rc.resizeSnapshot()
442+ if tc.installNew {
443+ require.Equal(t, sz(100, 50), snap.size)
444+ require.Equal(t, uint64(2), snap.epoch)
445+ require.Same(t, freshOwner, snap.source)
446+ return
447+ }
448+ require.Equal(t, sz(120, 40), snap.size)
449+ require.Equal(t, uint64(1), snap.epoch)
450+ require.Same(t, staleOwner, snap.source,
451+ "the stale callback must not replace the recorded attachment identity")
452+ })
453+ }
416 },454 },
417 },455 },
418 }456 }
@@ -573,7 +611,7 @@ func TestProducerInvalidations(t *testing.T) {
573 611
574 awaitInvalidation(t, invs)612 awaitInvalidation(t, invs)
575 requireNoInvalidation(t, invs)613 requireNoInvalidation(t, invs)
576- requireNoOutputFrame(t, sends)614+ requireNoCoordinatorOutputFrame(t, sends)
577 })615 })
578 }616 }
579 }617 }
@@ -626,13 +664,13 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
626 "the coordinator must record the latest requested resize before delegating")664 "the coordinator must record the latest requested resize before delegating")
627 require.Same(t, ac, snap.source)665 require.Same(t, ac, snap.source)
628 require.Equal(t, uint64(1), snap.epoch)666 require.Equal(t, uint64(1), snap.epoch)
629- requireNoOutputFrame(t, sends)667+ requireNoCoordinatorOutputFrame(t, sends)
630 668
631 timer.ch <- time.Time{}669 timer.ch <- time.Time{}
632 inv := awaitInvalidation(t, invs)670 inv := awaitInvalidation(t, invs)
633 require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")671 require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")
634 requireNoInvalidation(t, invs)672 requireNoInvalidation(t, invs)
635- requireNoOutputFrame(t, sends)673+ requireNoCoordinatorOutputFrame(t, sends)
636 })674 })
637 675
638 t.Run("stale generations stay rejected", func(t *testing.T) {676 t.Run("stale generations stay rejected", func(t *testing.T) {
@@ -647,13 +685,13 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
647 685
648 first.ch <- time.Time{}686 first.ch <- time.Time{}
649 requireNoInvalidation(t, invs)687 requireNoInvalidation(t, invs)
650- requireNoOutputFrame(t, sends)688+ requireNoCoordinatorOutputFrame(t, sends)
651 689
652 latest.ch <- time.Time{}690 latest.ch <- time.Time{}
653 inv := awaitInvalidation(t, invs)691 inv := awaitInvalidation(t, invs)
654 require.True(t, inv.reset)692 require.True(t, inv.reset)
655 requireNoInvalidation(t, invs)693 requireNoInvalidation(t, invs)
656- requireNoOutputFrame(t, sends)694+ requireNoCoordinatorOutputFrame(t, sends)
657 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, sess.renderCoordinator().resizeSnapshot().size)695 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, sess.renderCoordinator().resizeSnapshot().size)
658 })696 })
659 697
@@ -666,6 +704,6 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
666 704
667 timer.ch <- time.Time{}705 timer.ch <- time.Time{}
668 requireNoInvalidation(t, invs)706 requireNoInvalidation(t, invs)
669- requireNoOutputFrame(t, sends)707+ requireNoCoordinatorOutputFrame(t, sends)
670 })708 })
671 }709 }

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