detached fcb1644

test(daemon): cover sync completion after detach

commit
fcb164428c05a89ad30dd3716388cf73d5fdbada
author
brice · · 2026-07-11 21:56 UTC
parent
cf75aa0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +117 -0
M internal/usecase/daemon/render_test.go +117 -0
@@ -243,6 +243,123 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
243243 })
244244 }
245245
246+func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {
247+ t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {
248+ pty, steps, processed := newChannelPTY(t)
249+ d, target, targetClient, _ := newManualSessionWithPTYs(t, pty)
250+ clock := newCoordinatorMockClock(t, 8)
251+ d.clock = clock.clock
252+ rc := d.attachCoordinator(target, nil, targetClient, true)
253+
254+ // A viewer in a different session is previewing the headless target.
255+ viewer := &attachedClient{}
256+ viewer.initOverlays()
257+ viewer.overlays.pickerMu.Lock()
258+ viewer.overlays.pickerPreview = target.tabs[0]
259+ viewer.overlays.pickerMu.Unlock()
260+ d.sessions["viewer"] = &session{id: "viewer", client: viewer}
261+ previews := make(chan renderWake, 2)
262+ rc.subscribePreviewFor(viewer, func(w renderWake) { previews <- w })
263+
264+ d.sessWg.Add(1)
265+ go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
266+ steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")}
267+ awaitPTYReadProcessed(t, processed)
268+ drainCoordinatorTimers(clock) // detach must clear the pending output work.
269+
270+ target.mu.Lock()
271+ target.client = nil
272+ target.mu.Unlock()
273+ rc.noteDetach(targetClient)
274+ steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
275+ awaitPTYReadProcessed(t, processed)
276+
277+ fireCoordinatorTimer(t, drainCoordinatorTimers(clock), urgentRenderDeadline)
278+ wake := <-previews
279+ require.True(t, wake.urgent)
280+ require.Equal(t, 1, wake.coalesced)
281+ select {
282+ case duplicate := <-previews:
283+ t.Fatalf("sync completion must publish exactly one preview wake: %#v", duplicate)
284+ default:
285+ }
286+
287+ steps <- channelPTYStep{err: io.EOF}
288+ d.sessWg.Wait()
289+ })
290+
291+ t.Run("replacement receives one complete reset frame without later PTY output", func(t *testing.T) {
292+ pty, steps, processed := newChannelPTY(t)
293+ d, target, oldClient, _ := newManualSessionWithPTYs(t, pty)
294+ clock := newCoordinatorMockClock(t, 8)
295+ d.clock = clock.clock
296+ rc := d.attachCoordinator(target, nil, oldClient, true)
297+
298+ wakes := make(chan renderWake, 2)
299+ rc.opts.wake = func(w renderWake) { wakes <- w }
300+ d.sessWg.Add(1)
301+ go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
302+ steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")}
303+ awaitPTYReadProcessed(t, processed)
304+ drainCoordinatorTimers(clock)
305+
306+ replacement := &attachedClient{output: newOutputStateStream()}
307+ rc.noteReplace(oldClient, replacement, true)
308+ target.mu.Lock()
309+ target.client = replacement
310+ target.mu.Unlock()
311+ // The replacement's initial full paint remains gated by the batch. Its
312+ // reset must survive completion and coalesce with the completion wake.
313+ rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"})
314+ replacementTimers := drainCoordinatorTimers(clock)
315+ steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
316+ awaitPTYReadProcessed(t, processed)
317+ requireNoWake(t, wakes)
318+
319+ fireCoordinatorTimer(t, replacementTimers, urgentRenderDeadline)
320+ wake := <-wakes
321+ require.True(t, wake.urgent)
322+ require.True(t, wake.reset, "the replacement's cleared batch must repaint a complete frame")
323+ require.Equal(t, 2, wake.coalesced, "completion must coalesce with the replacement reset")
324+ require.Same(t, replacement, wake.attachment)
325+ select {
326+ case duplicate := <-wakes:
327+ t.Fatalf("sync completion must publish exactly one replacement wake: %#v", duplicate)
328+ default:
329+ }
330+
331+ steps <- channelPTYStep{err: io.EOF}
332+ d.sessWg.Wait()
333+ })
334+
335+ t.Run("headless pane without a preview stays quiet on completion", func(t *testing.T) {
336+ pty, steps, processed := newChannelPTY(t)
337+ d, target, client, _ := newManualSessionWithPTYs(t, pty)
338+ clock := newCoordinatorMockClock(t, 8)
339+ d.clock = clock.clock
340+ rc := d.attachCoordinator(target, nil, client, true)
341+ wakes := make(chan renderWake, 1)
342+ rc.opts.wake = func(w renderWake) { wakes <- w }
343+ target.mu.Lock()
344+ target.client = nil
345+ target.mu.Unlock()
346+ rc.noteDetach(client)
347+
348+ d.sessWg.Add(1)
349+ go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
350+ steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")}
351+ awaitPTYReadProcessed(t, processed)
352+ drainCoordinatorTimers(clock)
353+ steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
354+ awaitPTYReadProcessed(t, processed)
355+ require.Empty(t, drainCoordinatorTimers(clock))
356+ requireNoWake(t, wakes)
357+
358+ steps <- channelPTYStep{err: io.EOF}
359+ d.sessWg.Wait()
360+ })
361+}
362+
246363 func TestClearNonRenderablePaneDamage(t *testing.T) {
247364 newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) {
248365 t.Helper()
@@ -243,6 +243,123 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
243 })243 })
244 }244 }
245 245
246+func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {
247+ t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {
248+ pty, steps, processed := newChannelPTY(t)
249+ d, target, targetClient, _ := newManualSessionWithPTYs(t, pty)
250+ clock := newCoordinatorMockClock(t, 8)
251+ d.clock = clock.clock
252+ rc := d.attachCoordinator(target, nil, targetClient, true)
253+
254+ // A viewer in a different session is previewing the headless target.
255+ viewer := &attachedClient{}
256+ viewer.initOverlays()
257+ viewer.overlays.pickerMu.Lock()
258+ viewer.overlays.pickerPreview = target.tabs[0]
259+ viewer.overlays.pickerMu.Unlock()
260+ d.sessions["viewer"] = &session{id: "viewer", client: viewer}
261+ previews := make(chan renderWake, 2)
262+ rc.subscribePreviewFor(viewer, func(w renderWake) { previews <- w })
263+
264+ d.sessWg.Add(1)
265+ go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
266+ steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")}
267+ awaitPTYReadProcessed(t, processed)
268+ drainCoordinatorTimers(clock) // detach must clear the pending output work.
269+
270+ target.mu.Lock()
271+ target.client = nil
272+ target.mu.Unlock()
273+ rc.noteDetach(targetClient)
274+ steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
275+ awaitPTYReadProcessed(t, processed)
276+
277+ fireCoordinatorTimer(t, drainCoordinatorTimers(clock), urgentRenderDeadline)
278+ wake := <-previews
279+ require.True(t, wake.urgent)
280+ require.Equal(t, 1, wake.coalesced)
281+ select {
282+ case duplicate := <-previews:
283+ t.Fatalf("sync completion must publish exactly one preview wake: %#v", duplicate)
284+ default:
285+ }
286+
287+ steps <- channelPTYStep{err: io.EOF}
288+ d.sessWg.Wait()
289+ })
290+
291+ t.Run("replacement receives one complete reset frame without later PTY output", func(t *testing.T) {
292+ pty, steps, processed := newChannelPTY(t)
293+ d, target, oldClient, _ := newManualSessionWithPTYs(t, pty)
294+ clock := newCoordinatorMockClock(t, 8)
295+ d.clock = clock.clock
296+ rc := d.attachCoordinator(target, nil, oldClient, true)
297+
298+ wakes := make(chan renderWake, 2)
299+ rc.opts.wake = func(w renderWake) { wakes <- w }
300+ d.sessWg.Add(1)
301+ go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
302+ steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")}
303+ awaitPTYReadProcessed(t, processed)
304+ drainCoordinatorTimers(clock)
305+
306+ replacement := &attachedClient{output: newOutputStateStream()}
307+ rc.noteReplace(oldClient, replacement, true)
308+ target.mu.Lock()
309+ target.client = replacement
310+ target.mu.Unlock()
311+ // The replacement's initial full paint remains gated by the batch. Its
312+ // reset must survive completion and coalesce with the completion wake.
313+ rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"})
314+ replacementTimers := drainCoordinatorTimers(clock)
315+ steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
316+ awaitPTYReadProcessed(t, processed)
317+ requireNoWake(t, wakes)
318+
319+ fireCoordinatorTimer(t, replacementTimers, urgentRenderDeadline)
320+ wake := <-wakes
321+ require.True(t, wake.urgent)
322+ require.True(t, wake.reset, "the replacement's cleared batch must repaint a complete frame")
323+ require.Equal(t, 2, wake.coalesced, "completion must coalesce with the replacement reset")
324+ require.Same(t, replacement, wake.attachment)
325+ select {
326+ case duplicate := <-wakes:
327+ t.Fatalf("sync completion must publish exactly one replacement wake: %#v", duplicate)
328+ default:
329+ }
330+
331+ steps <- channelPTYStep{err: io.EOF}
332+ d.sessWg.Wait()
333+ })
334+
335+ t.Run("headless pane without a preview stays quiet on completion", func(t *testing.T) {
336+ pty, steps, processed := newChannelPTY(t)
337+ d, target, client, _ := newManualSessionWithPTYs(t, pty)
338+ clock := newCoordinatorMockClock(t, 8)
339+ d.clock = clock.clock
340+ rc := d.attachCoordinator(target, nil, client, true)
341+ wakes := make(chan renderWake, 1)
342+ rc.opts.wake = func(w renderWake) { wakes <- w }
343+ target.mu.Lock()
344+ target.client = nil
345+ target.mu.Unlock()
346+ rc.noteDetach(client)
347+
348+ d.sessWg.Add(1)
349+ go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
350+ steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")}
351+ awaitPTYReadProcessed(t, processed)
352+ drainCoordinatorTimers(clock)
353+ steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
354+ awaitPTYReadProcessed(t, processed)
355+ require.Empty(t, drainCoordinatorTimers(clock))
356+ requireNoWake(t, wakes)
357+
358+ steps <- channelPTYStep{err: io.EOF}
359+ d.sessWg.Wait()
360+ })
361+}
362+
246 func TestClearNonRenderablePaneDamage(t *testing.T) {363 func TestClearNonRenderablePaneDamage(t *testing.T) {
247 newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) {364 newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) {
248 t.Helper()365 t.Helper()

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