detached 07cd4c8

test(daemon): reproduce stale alternate screen exit

commit
07cd4c870e160b6a14d665abda8b2c523345d19b
author
brice · · 2026-07-13 05:53 UTC
parent
a3882e7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +50 -0
M internal/usecase/daemon/render_test.go +50 -0
@@ -254,6 +254,56 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
254254 })
255255 }
256256
257+func TestPTYReaderRestoresPrimaryScreenImmediatelyAfterDEC1049Exit(t *testing.T) {
258+ pty, steps, processed := newChannelPTY(t)
259+ d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
260+ // A one-frame local output window forces the replay to exercise the ACK
261+ // edge between each visible terminal state without relying on elapsed time.
262+ ac.output.maxOutstanding = 1
263+ clock := newCoordinatorMockClock(t, 8)
264+ d.clock = clock.clock
265+ rc := d.attachCoordinator(sess, nil, ac, true)
266+ client := vt.NewScreen(80, 25)
267+
268+ d.sessWg.Add(1)
269+ go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
270+
271+ replay := func(data []byte, acknowledge bool) ports.Output {
272+ t.Helper()
273+ steps <- channelPTYStep{data: data}
274+ awaitPTYReadProcessed(t, processed)
275+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
276+ frame := awaitOutputFrameWithoutSleep(t, sends)
277+ out := mustApplyOutput(t, client, frame)
278+ if acknowledge {
279+ ac.ackOutputState(out.NewStateNum)
280+ rc.notifyAck()
281+ }
282+ return out
283+ }
284+
285+ replay([]byte("PRIMARY"), true)
286+ primary := client.Frame.Clone()
287+
288+ // This is the PTY byte sequence emitted by the reported command: primary
289+ // content, a dense DEC alternate screen, then an immediate DEC restore.
290+ alternate := replay(append([]byte("\x1b[?1049h"), []byte(strings.Repeat("X", 80*23))...), false)
291+ require.Equal(t, 'X', client.Frame.At(79, 23).Rune, "fixture must make alternate cells client-visible")
292+
293+ steps <- channelPTYStep{data: []byte("\x1b[?1049l")}
294+ awaitPTYReadProcessed(t, processed)
295+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
296+ requireNoCoordinatorOutputFrame(t, sends)
297+ ac.ackOutputState(alternate.NewStateNum)
298+ rc.notifyAck()
299+ exit := mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
300+ require.NotEmpty(t, exit.Data, "the first post-exit output must actively remove alternate cells")
301+ require.Equal(t, frameRows(primary), frameRows(client.Frame), "the first output after DEC 1049 exit must restore primary cells and erase every alternate glyph")
302+
303+ steps <- channelPTYStep{err: io.EOF}
304+ d.sessWg.Wait()
305+}
306+
257307 func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {
258308 t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {
259309 pty, steps, processed := newChannelPTY(t)
@@ -254,6 +254,56 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
254 })254 })
255 }255 }
256 256
257+func TestPTYReaderRestoresPrimaryScreenImmediatelyAfterDEC1049Exit(t *testing.T) {
258+ pty, steps, processed := newChannelPTY(t)
259+ d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
260+ // A one-frame local output window forces the replay to exercise the ACK
261+ // edge between each visible terminal state without relying on elapsed time.
262+ ac.output.maxOutstanding = 1
263+ clock := newCoordinatorMockClock(t, 8)
264+ d.clock = clock.clock
265+ rc := d.attachCoordinator(sess, nil, ac, true)
266+ client := vt.NewScreen(80, 25)
267+
268+ d.sessWg.Add(1)
269+ go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
270+
271+ replay := func(data []byte, acknowledge bool) ports.Output {
272+ t.Helper()
273+ steps <- channelPTYStep{data: data}
274+ awaitPTYReadProcessed(t, processed)
275+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
276+ frame := awaitOutputFrameWithoutSleep(t, sends)
277+ out := mustApplyOutput(t, client, frame)
278+ if acknowledge {
279+ ac.ackOutputState(out.NewStateNum)
280+ rc.notifyAck()
281+ }
282+ return out
283+ }
284+
285+ replay([]byte("PRIMARY"), true)
286+ primary := client.Frame.Clone()
287+
288+ // This is the PTY byte sequence emitted by the reported command: primary
289+ // content, a dense DEC alternate screen, then an immediate DEC restore.
290+ alternate := replay(append([]byte("\x1b[?1049h"), []byte(strings.Repeat("X", 80*23))...), false)
291+ require.Equal(t, 'X', client.Frame.At(79, 23).Rune, "fixture must make alternate cells client-visible")
292+
293+ steps <- channelPTYStep{data: []byte("\x1b[?1049l")}
294+ awaitPTYReadProcessed(t, processed)
295+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
296+ requireNoCoordinatorOutputFrame(t, sends)
297+ ac.ackOutputState(alternate.NewStateNum)
298+ rc.notifyAck()
299+ exit := mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))
300+ require.NotEmpty(t, exit.Data, "the first post-exit output must actively remove alternate cells")
301+ require.Equal(t, frameRows(primary), frameRows(client.Frame), "the first output after DEC 1049 exit must restore primary cells and erase every alternate glyph")
302+
303+ steps <- channelPTYStep{err: io.EOF}
304+ d.sessWg.Wait()
305+}
306+
257 func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {307 func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {
258 t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {308 t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {
259 pty, steps, processed := newChannelPTY(t)309 pty, steps, processed := newChannelPTY(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