detached 39df6d2

test(daemon): cover create switch and watchdog ordering

commit
39df6d2dfeb71263653e2f96588aff2efab8b09a
author
brice · · 2026-07-11 19:37 UTC
parent
5747da7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
649649 requireNoWake(t, h.wakes)
650650 })
651651
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+
652677 t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) {
653678 h := newCoordinatorHarness(t)
654679 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) {
13541354 tr.EXPECT().Close().Return(nil).Maybe()
13551355 sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)
13561356 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+
13571367 require.NoError(t, d.createSessionAndSwitch(sess, ac, "next"))
13581368 got := ac.sess.Get()
13591369 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()
13601381 got.mu.Lock()
13611382 require.True(t, got.terminal.TrueColor)
13621383 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()

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 2

Tags 13

↑↓ moveenter openesc close