test(daemon): cover handoff publication ordering
- commit
- 9a215e46ae2aa92025a22dfa78a0c4a33925aba5
- author
- brice · · 2026-07-11 19:45 UTC
- parent
- 70a176f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 258 | 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 | 297 | func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |
| 262 | 298 | p1, releasePTY1 := newBlockingPTY(t) | |
| 263 | 299 | 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{}) { | |||
| 1176 | 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 | 1214 | func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) { | |
| 1180 | 1215 | clock := portsmocks.NewMockClock(t) | |
| 1181 | 1216 | 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) | ||