test(daemon): cover create switch and watchdog ordering
- commit
- 39df6d2dfeb71263653e2f96588aff2efab8b09a
- author
- brice · · 2026-07-11 19:37 UTC
- parent
- 5747da7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +46 -0
M internal/usecase/daemon/render_coordinator_test.go +25 -0
| @@ -649,6 +649,31 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) { | |||
| 649 | 649 | requireNoWake(t, h.wakes) | |
| 650 | 650 | }) | |
| 651 | 651 | ||
| 652 | + | t.Run("watchdog retains gate while force closes the VT batch", func(t *testing.T) { | |
| 653 | + | h := newCoordinatorHarness(t) | |
| 654 | + | entered := make(chan struct{}) | |
| 655 | + | release := make(chan struct{}) | |
| 656 | + | h.rc.noteSyncBegin(nil, 1, func() { | |
| 657 | + | close(entered) | |
| 658 | + | <-release | |
| 659 | + | }) | |
| 660 | + | watchdogs := h.armedTimers(t) | |
| 661 | + | require.NotEmpty(t, watchdogs) | |
| 662 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput}) | |
| 663 | + | watchdogs[0].ch <- time.Time{} | |
| 664 | + | <-entered | |
| 665 | + | ||
| 666 | + | // A concurrent deadline/fire sees the retained batch gate and cannot | |
| 667 | + | // publish the partial VT state while force is blocked. | |
| 668 | + | h.rc.fireCurrent(false) | |
| 669 | + | requireNoWake(t, h.wakes) | |
| 670 | + | close(release) | |
| 671 | + | w := awaitWake(t, h.wakes) | |
| 672 | + | require.True(t, w.watchdog) | |
| 673 | + | require.True(t, w.urgent) | |
| 674 | + | requireNoWake(t, h.wakes) | |
| 675 | + | }) | |
| 676 | + | ||
| 652 | 677 | t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) { | |
| 653 | 678 | h := newCoordinatorHarness(t) | |
| 654 | 679 | h.syncActive.Store(true) | |
| @@ -649,6 +649,31 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) { | |||||
| 649 | requireNoWake(t, h.wakes) | 649 | requireNoWake(t, h.wakes) | ||
| 650 | }) | 650 | }) | ||
| 651 | 651 | ||||
| 652 | + | t.Run("watchdog retains gate while force closes the VT batch", func(t *testing.T) { | |||
| 653 | + | h := newCoordinatorHarness(t) | |||
| 654 | + | entered := make(chan struct{}) | |||
| 655 | + | release := make(chan struct{}) | |||
| 656 | + | h.rc.noteSyncBegin(nil, 1, func() { | |||
| 657 | + | close(entered) | |||
| 658 | + | <-release | |||
| 659 | + | }) | |||
| 660 | + | watchdogs := h.armedTimers(t) | |||
| 661 | + | require.NotEmpty(t, watchdogs) | |||
| 662 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput}) | |||
| 663 | + | watchdogs[0].ch <- time.Time{} | |||
| 664 | + | <-entered | |||
| 665 | + | ||||
| 666 | + | // A concurrent deadline/fire sees the retained batch gate and cannot | |||
| 667 | + | // publish the partial VT state while force is blocked. | |||
| 668 | + | h.rc.fireCurrent(false) | |||
| 669 | + | requireNoWake(t, h.wakes) | |||
| 670 | + | close(release) | |||
| 671 | + | w := awaitWake(t, h.wakes) | |||
| 672 | + | require.True(t, w.watchdog) | |||
| 673 | + | require.True(t, w.urgent) | |||
| 674 | + | requireNoWake(t, h.wakes) | |||
| 675 | + | }) | |||
| 676 | + | ||||
| 652 | t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) { | 677 | t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) { | ||
| 653 | h := newCoordinatorHarness(t) | 678 | h := newCoordinatorHarness(t) | ||
| 654 | h.syncActive.Store(true) | 679 | h.syncActive.Store(true) | ||
M internal/usecase/daemon/session_test.go +21 -0
| @@ -1354,9 +1354,30 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 1354 | 1354 | tr.EXPECT().Close().Return(nil).Maybe() | |
| 1355 | 1355 | sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr) | |
| 1356 | 1356 | require.NoError(t, err) | |
| 1357 | + | // The source coordinator is deliberately made pending so the switch must | |
| 1358 | + | // invalidate it rather than letting its stale callback render for ac. | |
| 1359 | + | sourceCoordinator := sess.renderCoordinator() | |
| 1360 | + | require.NotNil(t, sourceCoordinator) | |
| 1361 | + | sourceCoordinator.invalidate(renderInvalidation{class: invalidateOutput}) | |
| 1362 | + | ac.sendMu.Lock() | |
| 1363 | + | ac.output.next = 3 | |
| 1364 | + | ac.output.acked = 1 | |
| 1365 | + | ac.sendMu.Unlock() | |
| 1366 | + | ||
| 1357 | 1367 | require.NoError(t, d.createSessionAndSwitch(sess, ac, "next")) | |
| 1358 | 1368 | got := ac.sess.Get() | |
| 1359 | 1369 | require.NotNil(t, got) | |
| 1370 | + | sourceCoordinator.mu.Lock() | |
| 1371 | + | require.Nil(t, sourceCoordinator.attachment, "source callbacks must be stale after handoff") | |
| 1372 | + | sourceCoordinator.mu.Unlock() | |
| 1373 | + | destinationCoordinator := got.renderCoordinator() | |
| 1374 | + | require.NotNil(t, destinationCoordinator, "destination coordinator precedes first paint") | |
| 1375 | + | destinationCoordinator.mu.Lock() | |
| 1376 | + | require.Same(t, ac, destinationCoordinator.attachment) | |
| 1377 | + | destinationCoordinator.mu.Unlock() | |
| 1378 | + | ac.sendMu.Lock() | |
| 1379 | + | require.Equal(t, uint64(1), ac.output.next-ac.output.acked, "only the destination first paint may follow the rebase") | |
| 1380 | + | ac.sendMu.Unlock() | |
| 1360 | 1381 | got.mu.Lock() | |
| 1361 | 1382 | require.True(t, got.terminal.TrueColor) | |
| 1362 | 1383 | got.mu.Unlock() | |
| @@ -1354,9 +1354,30 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) { | |||||
| 1354 | tr.EXPECT().Close().Return(nil).Maybe() | 1354 | tr.EXPECT().Close().Return(nil).Maybe() | ||
| 1355 | sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr) | 1355 | sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr) | ||
| 1356 | require.NoError(t, err) | 1356 | require.NoError(t, err) | ||
| 1357 | + | // The source coordinator is deliberately made pending so the switch must | |||
| 1358 | + | // invalidate it rather than letting its stale callback render for ac. | |||
| 1359 | + | sourceCoordinator := sess.renderCoordinator() | |||
| 1360 | + | require.NotNil(t, sourceCoordinator) | |||
| 1361 | + | sourceCoordinator.invalidate(renderInvalidation{class: invalidateOutput}) | |||
| 1362 | + | ac.sendMu.Lock() | |||
| 1363 | + | ac.output.next = 3 | |||
| 1364 | + | ac.output.acked = 1 | |||
| 1365 | + | ac.sendMu.Unlock() | |||
| 1366 | + | ||||
| 1357 | require.NoError(t, d.createSessionAndSwitch(sess, ac, "next")) | 1367 | require.NoError(t, d.createSessionAndSwitch(sess, ac, "next")) | ||
| 1358 | got := ac.sess.Get() | 1368 | got := ac.sess.Get() | ||
| 1359 | require.NotNil(t, got) | 1369 | require.NotNil(t, got) | ||
| 1370 | + | sourceCoordinator.mu.Lock() | |||
| 1371 | + | require.Nil(t, sourceCoordinator.attachment, "source callbacks must be stale after handoff") | |||
| 1372 | + | sourceCoordinator.mu.Unlock() | |||
| 1373 | + | destinationCoordinator := got.renderCoordinator() | |||
| 1374 | + | require.NotNil(t, destinationCoordinator, "destination coordinator precedes first paint") | |||
| 1375 | + | destinationCoordinator.mu.Lock() | |||
| 1376 | + | require.Same(t, ac, destinationCoordinator.attachment) | |||
| 1377 | + | destinationCoordinator.mu.Unlock() | |||
| 1378 | + | ac.sendMu.Lock() | |||
| 1379 | + | require.Equal(t, uint64(1), ac.output.next-ac.output.acked, "only the destination first paint may follow the rebase") | |||
| 1380 | + | ac.sendMu.Unlock() | |||
| 1360 | got.mu.Lock() | 1381 | got.mu.Lock() | ||
| 1361 | require.True(t, got.terminal.TrueColor) | 1382 | require.True(t, got.terminal.TrueColor) | ||
| 1362 | got.mu.Unlock() | 1383 | got.mu.Unlock() | ||