test(daemon): cover sync completion after detach
- commit
- fcb164428c05a89ad30dd3716388cf73d5fdbada
- author
- brice · · 2026-07-11 21:56 UTC
- parent
- cf75aa0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 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 | 363 | func TestClearNonRenderablePaneDamage(t *testing.T) { | |
| 247 | 364 | newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) { | |
| 248 | 365 | 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() | ||