detached 9a215e4

test(daemon): cover handoff publication ordering

commit
9a215e46ae2aa92025a22dfa78a0c4a33925aba5
author
brice · · 2026-07-11 19:45 UTC
parent
70a176f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +71 -0
M internal/usecase/daemon/picker_test.go +36 -0
@@ -258,6 +258,42 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
258258 awaitFrame(t, sends1, ports.MsgOutput)
259259 }
260260
261+func TestPickerDisplacementCancelsOldResizePaint(t *testing.T) {
262+ p1, releasePTY1 := newBlockingPTY(t)
263+ p2, releasePTY2 := newBlockingPTY(t)
264+ defer releasePTY1()
265+ defer releasePTY2()
266+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
267+ d := newTestDaemon(t, nil, clock)
268+ tr1, _ := newCapturingTransport(t)
269+ tr2, _ := newCapturingTransport(t)
270+ ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
271+ ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
272+ sctx1, cancel1 := context.WithCancel(d.serveCtx)
273+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
274+ defer cancel1()
275+ defer cancel2()
276+ sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}
277+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
278+ ac1.setSession(sess1)
279+ ac2.setSession(sess2)
280+ d.sessions[sess1.id], d.sessions[sess2.id] = sess1, sess2
281+
282+ d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24})
283+ timer := <-clock.timers
284+ ac2.sendMu.Lock()
285+ before := ac2.resizePaintGeneration
286+ require.True(t, ac2.resizePaintPending)
287+ ac2.sendMu.Unlock()
288+
289+ require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id}))
290+ ac2.sendMu.Lock()
291+ require.False(t, ac2.resizePaintPending)
292+ require.Equal(t, before+1, ac2.resizePaintGeneration)
293+ ac2.sendMu.Unlock()
294+ timer.ch <- time.Time{}
295+}
296+
261297 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
262298 p1, releasePTY1 := newBlockingPTY(t)
263299 p2, releasePTY2 := newBlockingPTY(t)
@@ -258,6 +258,42 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
258 awaitFrame(t, sends1, ports.MsgOutput)258 awaitFrame(t, sends1, ports.MsgOutput)
259 }259 }
260 260
261+func TestPickerDisplacementCancelsOldResizePaint(t *testing.T) {
262+ p1, releasePTY1 := newBlockingPTY(t)
263+ p2, releasePTY2 := newBlockingPTY(t)
264+ defer releasePTY1()
265+ defer releasePTY2()
266+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
267+ d := newTestDaemon(t, nil, clock)
268+ tr1, _ := newCapturingTransport(t)
269+ tr2, _ := newCapturingTransport(t)
270+ ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
271+ ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
272+ sctx1, cancel1 := context.WithCancel(d.serveCtx)
273+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
274+ defer cancel1()
275+ defer cancel2()
276+ sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}
277+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
278+ ac1.setSession(sess1)
279+ ac2.setSession(sess2)
280+ d.sessions[sess1.id], d.sessions[sess2.id] = sess1, sess2
281+
282+ d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24})
283+ timer := <-clock.timers
284+ ac2.sendMu.Lock()
285+ before := ac2.resizePaintGeneration
286+ require.True(t, ac2.resizePaintPending)
287+ ac2.sendMu.Unlock()
288+
289+ require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id}))
290+ ac2.sendMu.Lock()
291+ require.False(t, ac2.resizePaintPending)
292+ require.Equal(t, before+1, ac2.resizePaintGeneration)
293+ ac2.sendMu.Unlock()
294+ timer.ch <- time.Time{}
295+}
296+
261 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {297 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
262 p1, releasePTY1 := newBlockingPTY(t)298 p1, releasePTY1 := newBlockingPTY(t)
263 p2, releasePTY2 := newBlockingPTY(t)299 p2, releasePTY2 := newBlockingPTY(t)
M internal/usecase/daemon/render_coordinator_test.go +35 -0
@@ -1176,6 +1176,41 @@ func requireWorkerExit(t *testing.T, done <-chan struct{}) {
11761176 t.Fatal("cancelled coordinator timer worker did not exit")
11771177 }
11781178
1179+func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstall(t *testing.T) {
1180+ p, releasePTY := newBlockingPTY(t)
1181+ defer releasePTY()
1182+ d, sess, owner, ownerSends := newManualSessionWithPTYs(t, p)
1183+ replacementTransport, replacementSends := newCapturingTransport(t)
1184+ replacement := &attachedClient{tr: replacementTransport, output: newOutputStateStream(), size: owner.size}
1185+ replacement.initOverlays()
1186+ replacement.setSession(sess)
1187+
1188+ clock := newCoordinatorMockClock(t, 2)
1189+ d.clock = clock.clock
1190+ rc := newRenderCoordinator(renderCoordinatorOptions{
1191+ clock: clock.clock,
1192+ wake: func(w renderWake) {
1193+ // This is the production ownership boundary: composition must use
1194+ // the coordinator's captured attachment, never sess.client.
1195+ d.paint(sess, w.attachment, w.reset)
1196+ },
1197+ ackReady: func() bool { return true },
1198+ })
1199+ rc.attach(owner)
1200+ sess.installRenderCoordinator(rc)
1201+ rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
1202+ timer := awaitCoordinatorScheduledTimer(t, clock)
1203+
1204+ // Model attachClient's publication window exactly: sess.client is new,
1205+ // but coordinator replacement has not yet invalidated the old deadline.
1206+ sess.mu.Lock()
1207+ sess.client = replacement
1208+ sess.mu.Unlock()
1209+ timer.ch <- time.Time{}
1210+ requireNoCoordinatorOutputFrame(t, replacementSends)
1211+ requireNoCoordinatorOutputFrame(t, ownerSends)
1212+}
1213+
11791214 func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) {
11801215 clock := portsmocks.NewMockClock(t)
11811216 timer := portsmocks.NewMockTimer(t)
@@ -1176,6 +1176,41 @@ func requireWorkerExit(t *testing.T, done <-chan struct{}) {
1176 t.Fatal("cancelled coordinator timer worker did not exit")1176 t.Fatal("cancelled coordinator timer worker did not exit")
1177 }1177 }
1178 1178
1179+func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstall(t *testing.T) {
1180+ p, releasePTY := newBlockingPTY(t)
1181+ defer releasePTY()
1182+ d, sess, owner, ownerSends := newManualSessionWithPTYs(t, p)
1183+ replacementTransport, replacementSends := newCapturingTransport(t)
1184+ replacement := &attachedClient{tr: replacementTransport, output: newOutputStateStream(), size: owner.size}
1185+ replacement.initOverlays()
1186+ replacement.setSession(sess)
1187+
1188+ clock := newCoordinatorMockClock(t, 2)
1189+ d.clock = clock.clock
1190+ rc := newRenderCoordinator(renderCoordinatorOptions{
1191+ clock: clock.clock,
1192+ wake: func(w renderWake) {
1193+ // This is the production ownership boundary: composition must use
1194+ // the coordinator's captured attachment, never sess.client.
1195+ d.paint(sess, w.attachment, w.reset)
1196+ },
1197+ ackReady: func() bool { return true },
1198+ })
1199+ rc.attach(owner)
1200+ sess.installRenderCoordinator(rc)
1201+ rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
1202+ timer := awaitCoordinatorScheduledTimer(t, clock)
1203+
1204+ // Model attachClient's publication window exactly: sess.client is new,
1205+ // but coordinator replacement has not yet invalidated the old deadline.
1206+ sess.mu.Lock()
1207+ sess.client = replacement
1208+ sess.mu.Unlock()
1209+ timer.ch <- time.Time{}
1210+ requireNoCoordinatorOutputFrame(t, replacementSends)
1211+ requireNoCoordinatorOutputFrame(t, ownerSends)
1212+}
1213+
1179 func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) {1214 func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) {
1180 clock := portsmocks.NewMockClock(t)1215 clock := portsmocks.NewMockClock(t)
1181 timer := portsmocks.NewMockTimer(t)1216 timer := portsmocks.NewMockTimer(t)

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