detached 6bc1da2

Merge pull request #74 from bnema/perf/render-coordinator

perf(daemon): coordinate session rendering
commit
6bc1da21c6bde636e57a31b886fbd66d807aadde
author
bnema · · 2026-07-12 05:30 UTC
committer
GitHub · · 2026-07-12 05:30 UTC
merge of
ca3be6d + 4d8f902 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
41 files · +3826 -1399
M docs/performance.md +14 -10
@@ -1,8 +1,8 @@
1 # Performance methodology (M5)1 # Performance methodology (M5)
2 2
3-This baseline measures in-process daemon work only. It deliberately does **not**3+This baseline measures in-process daemon work only. It includes coordinator-enabled
4-measure coordinator behavior, chunk encoding, compression, GC tuning, or real4+fixture scheduling metrics, but deliberately does **not** measure chunk
5-network latency. Output metrics count frames and encoded output data produced5+encoding, compression, GC tuning, or real network latency. Output metrics count frames and encoded output data produced
6 by the daemon; snapshot writes and serialized bytes are local snapshot-store6 by the daemon; snapshot writes and serialized bytes are local snapshot-store
7 proxies. These metrics approximate local rendering, IPC payload, and persistence7 proxies. These metrics approximate local rendering, IPC payload, and persistence
8 work, not a Unix socket, SSH, or UDP transport. Real transport impairment and8 work, not a Unix socket, SSH, or UDP transport. Real transport impairment and
@@ -40,13 +40,18 @@ prefix and patterned text. The layouts are:
40 - 4 tabs x 4 panes40 - 4 tabs x 4 panes
41 - 8 tabs x 1 pane41 - 8 tabs x 1 pane
42 42
43-The fixture starts no PTY readers, schedulers, clocks, or transports. It primes43+The fixture starts no PTY readers or transports. It primes the renderer shadow
44-the renderer shadow before timing; live paint then alternates fixed writes. The44+before timing; live paint then alternates fixed writes through the coordinator.
45-reported `outputframes/op` value counts output messages. `outputbytes/op`45+The reported `outputframes/op` value counts output messages. `outputbytes/op`
46 counts their encoded terminal data and excludes the 24-byte output header;46 counts their encoded terminal data and excludes the 24-byte output header;
47 `framepayloadbytes/op` counts the complete output payload including that header.47 `framepayloadbytes/op` counts the complete output payload including that header.
48 `snapshotwrites/op` and `snapshotbytes/op` are local snapshot-store writes and48 `snapshotwrites/op` and `snapshotbytes/op` are local snapshot-store writes and
49-serialized bytes. They must not be presented as network throughput or latency.49+serialized bytes. `coordinatorinvalidations/op`, `coordinatorwakes/op`, and
50+`coordinatorcoalesced/op` respectively count scheduling requests, delivered
51+wakes, and requests coalesced into those wakes; `coordinatorcoalescingratio`
52+is the average number of coalesced invalidations delivered per wake. They must
53+not be presented as network
54+throughput or latency.
50 55
51 Capture a pre-change result outside the repository, including Go and kernel56 Capture a pre-change result outside the repository, including Go and kernel
52 context:57 context:
@@ -82,9 +87,8 @@ wide row widths; `B/op` may grow by at most 2x, a conservative relative
82 allowance that catches full cell-row copies without depending on an absolute87 allowance that catches full cell-row copies without depending on an absolute
83 Go-version-specific budget. This scope does not yet include an88 Go-version-specific budget. This scope does not yet include an
84 incremental/full-text search index: queries still scan the immutable document89 incremental/full-text search index: queries still scan the immutable document
85-and produce a fresh match list. Coordinator90+and produce a fresh match list. Chunk encoding, compression, GC tuning, and real Unix-socket, SSH, or UDP
86-behavior, chunk encoding, compression, GC tuning, and real Unix-socket, SSH, or91+impairment/latency remain out of scope.
87-UDP impairment/latency remain out of scope.
88 92
89 ## Lower-level smoke baseline93 ## Lower-level smoke baseline
90 94
M internal/usecase/daemon/attention.go +2 -2
@@ -49,7 +49,7 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) {
49 if idx, ok := oldestAttentionTab(sess); ok {49 if idx, ok := oldestAttentionTab(sess); ok {
50 if sess.switchTab(idx) {50 if sess.switchTab(idx) {
51 d.activateTab(sess, sess.activeTab())51 d.activateTab(sess, sess.activeTab())
52- d.paint(sess, ac, true)52+ d.invalidateRender(sess, ac, true, "attention.go")
53 }53 }
54 return54 return
55 }55 }
@@ -119,7 +119,7 @@ func (d *Daemon) repaintAttachedClients(sess *session) {
119 ac := sess.client119 ac := sess.client
120 sess.mu.Unlock()120 sess.mu.Unlock()
121 if ac != nil {121 if ac != nil {
122- d.paint(sess, ac, false)122+ d.invalidateRender(sess, ac, false, "attention.go")
123 }123 }
124 }124 }
125 125
M internal/usecase/daemon/bar_refresh.go +5 -7
@@ -306,12 +306,10 @@ func (d *Daemon) pokeSessionRender(sess *session) {
306 if sess == nil {306 if sess == nil {
307 return307 return
308 }308 }
309- if tb := sess.activeTab(); tb != nil {309+ sess.mu.Lock()
310- tb.mu.Lock()310+ ac := sess.client
311- p := tb.focusedPane()311+ sess.mu.Unlock()
312- tb.mu.Unlock()312+ if ac != nil {
313- if p != nil {313+ d.invalidateRender(sess, ac, false, "bar_refresh.go")
314- signal(p.dirty)
315- }
316 }314 }
317 }315 }
M internal/usecase/daemon/client.go +96 -55
@@ -1,27 +1,8 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the1+// Package daemon holds vev's server-side session multiplexer use case.
2-// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.
4 //2 //
5-// Concurrency model (sessions own one or more PTY-backed tabs):3+// Lock ordering: never acquire sendMu or session/daemon locks while holding a
6-//4+// pane lock. Coordinator callbacks and timer methods run without coordinator.mu;
7-// - Serve runs the accept loop. Each accepted connection is handled by its5+// see the lock-specific comments on attachedClient and renderCoordinator.
8-// own goroutine (handleConn): it reads the first frame and routes it to a
9-// session create/attach, a list, or a kill.
10-// - Per session there are exactly two long-lived goroutines: the PTY reader
11-// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12-// and the render scheduler (debounces dirties and paints the attached
13-// client). Both are tied to the session context and unwind when the
14-// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15-// scheduler).
16-// - The daemon exits (Serve returns) when the last session is removed, or
17-// when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).
19-//
20-// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25 package daemon6 package daemon
26 7
27 import (8 import (
@@ -38,13 +19,21 @@ import (
38 )19 )
39 20
40 type attachedClient struct {21 type attachedClient struct {
41- tr ports.Transport22+ tr ports.Transport
42- output *outputStateStream23+ output *outputStateStream
43- overlays *overlayRuntime24+ overlays *overlayRuntime
44- clientID [16]byte25+ overlayOnce sync.Once
45- resumeCapable bool26+ clientID [16]byte
46- resumeToken uint6427+ resumeCapable bool
47- parked bool28+ resumeToken uint64
29+ parked bool
30+ // coordinatorEpoch is published atomically by renderCoordinator lifecycle
31+ // transitions. A wake verifies it again after acquiring sendMu, so a parked
32+ // attachment reused by resume cannot carry an old wake to its new transport.
33+ coordinatorEpoch atomic.Uint64
34+ // coordinatorReadyEpoch is non-zero only after Welcome completed for this
35+ // exact coordinator incarnation. paint rechecks it under sendMu.
36+ coordinatorReadyEpoch atomic.Uint64
48 echoAck atomic.Uint6437 echoAck atomic.Uint64
49 bars barCache // only touched while sendMu is held38 bars barCache // only touched while sendMu is held
50 composed composedFrameCache // only touched while sendMu is held39 composed composedFrameCache // only touched while sendMu is held
@@ -115,9 +104,7 @@ func (ac *attachedClient) cancelResizePaint() {
115 }104 }
116 105
117 func (ac *attachedClient) initOverlays() {106 func (ac *attachedClient) initOverlays() {
118- if ac.overlays == nil {107+ ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })
119- ac.overlays = newOverlayRuntime(ac)
120- }
121 }108 }
122 109
123 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }110 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
@@ -197,23 +184,6 @@ func (ac *attachedClient) ackOutputState(state uint64) {
197 ac.output.ack(state)184 ac.output.ack(state)
198 }185 }
199 186
200-// takeDeferredPaint reports whether a deferred diff paint is now flushable
201-// because acks have caught up, clearing the flag when it is. The caller must
202-// then paint outside sendMu (paint re-acquires it).
203-func (ac *attachedClient) takeDeferredPaint() (reset bool, ok bool) {
204- ac.sendMu.Lock()
205- defer ac.sendMu.Unlock()
206- return ac.output.takeDeferred()
207-}
208-
209-// clearPaintDeferred drops any pending deferred-paint flag. Used on park so a
210-// resumed client does not inherit a stale deferral.
211-func (ac *attachedClient) clearPaintDeferred() {
212- ac.sendMu.Lock()
213- ac.output.clearDeferred()
214- ac.sendMu.Unlock()
215-}
216-
217 // send serialises a frame onto the client's transport.187 // send serialises a frame onto the client's transport.
218 func (ac *attachedClient) send(f ports.Frame) error {188 func (ac *attachedClient) send(f ports.Frame) error {
219 _, err := ac.sendTransport(f)189 _, err := ac.sendTransport(f)
@@ -391,17 +361,78 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
391 ac.initOverlays()361 ac.initOverlays()
392 ac.setSession(sess)362 ac.setSession(sess)
393 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)363 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
364+ // Daemon attachment callers hold d.mu, serialising this preparation with
365+ // other publications. Bind coordinator ownership before sess.client becomes
366+ // visible: an old deadline can then never target this new output chain.
394 sess.mu.Lock()367 sess.mu.Lock()
395 old := sess.client368 old := sess.client
396- sess.client = ac
397 name := sess.name369 name := sess.name
398 sess.mu.Unlock()370 sess.mu.Unlock()
371+ d.attachCoordinator(sess, old, ac, false)
372+ sess.mu.Lock()
373+ sess.client = ac
374+ sess.mu.Unlock()
399 d.touchMRU(sess)375 d.touchMRU(sess)
400 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)376 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
401 d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)377 d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
402 return ac, old378 return ac, old
403 }379 }
404 380
381+// handoffCoordinator prepares a cross-session ownership transfer before the
382+// destination publishes sess.client. It never takes daemon/session locks while
383+// taking sendMu, avoiding a d.mu/sendMu cycle with transport callbacks.
384+func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) {
385+ if rc := from.renderCoordinator(); rc != nil {
386+ rc.noteDetach(current)
387+ }
388+ current.sendMu.Lock()
389+ current.output.rebase()
390+ current.sendMu.Unlock()
391+ d.attachCoordinator(target, old, current, true)
392+}
393+
394+// attachCoordinator is the sole direct attachment handoff. It creates at
395+// most one coordinator for sess and changes its identity before any caller
396+// can publish resize or render state for the new client.
397+func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {
398+ sess.mu.Lock()
399+ rc := sess.renderCoordinator()
400+ if rc == nil {
401+ rc = newRenderCoordinator(renderCoordinatorOptions{
402+ clock: d.clock,
403+ wake: func(w renderWake) {
404+ // Never reread sess.client here: a wake is bound to both its
405+ // attachment and its coordinator incarnation. The second check in
406+ // paint occurs under sendMu, closing a park/resume race after this
407+ // unlocked coordinator validation.
408+ if w.attachment != nil && rc.wakeCurrent(w) {
409+ d.paintCoordinatorWake(sess, w)
410+ }
411+ },
412+ ackReady: func() bool {
413+ sess.mu.Lock()
414+ attached := sess.client
415+ sess.mu.Unlock()
416+ if attached == nil {
417+ return false
418+ }
419+ attached.sendMu.Lock()
420+ ready := !attached.output.atCapacity()
421+ attached.sendMu.Unlock()
422+ return ready
423+ },
424+ })
425+ sess.installRenderCoordinator(rc)
426+ }
427+ sess.mu.Unlock()
428+ if old != nil {
429+ rc.noteReplace(old, current, ready)
430+ } else if current != nil {
431+ rc.attachWithReadiness(current, ready)
432+ }
433+ return rc
434+}
435+
405 // resetScreenDefaultColors clears the known default foreground/background436 // resetScreenDefaultColors clears the known default foreground/background
406 // colors on every tab in sess. Called once a client has been detached, this437 // colors on every tab in sess. Called once a client has been detached, this
407 // makes child OSC 10/11 queries go back to being swallowed (Known=false)438 // makes child OSC 10/11 queries go back to being swallowed (Known=false)
@@ -449,7 +480,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
449 d.resizeForFirstPaint(sess, ac, clientSize)480 d.resizeForFirstPaint(sess, ac, clientSize)
450 }481 }
451 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)482 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
452- d.paint(sess, ac, true)483+ d.invalidateRenderNow(sess, ac, true, "client.go")
453 d.activateTab(sess, tb)484 d.activateTab(sess, tb)
454 }485 }
455 486
@@ -503,8 +534,8 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
503 case ports.MsgAck:534 case ports.MsgAck:
504 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {535 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
505 ac.ackOutputState(ack.AckedStateNum)536 ac.ackOutputState(ack.AckedStateNum)
506- if reset, ok := ac.takeDeferredPaint(); ok {537+ if rc := sess.renderCoordinator(); rc != nil {
507- d.paint(sess, ac, reset)538+ rc.notifyAck()
508 }539 }
509 }540 }
510 case ports.MsgPing:541 case ports.MsgPing:
@@ -526,6 +557,9 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
526 return // already displaced by a newer client; nothing to do557 return // already displaced by a newer client; nothing to do
527 }558 }
528 ac.cancelResizePaint()559 ac.cancelResizePaint()
560+ if rc := sess.renderCoordinator(); rc != nil {
561+ rc.noteDetach(ac)
562+ }
529 d.unregisterPreview(ac)563 d.unregisterPreview(ac)
530 sess.mu.Lock()564 sess.mu.Lock()
531 ephemeral := sess.ephemeral565 ephemeral := sess.ephemeral
@@ -576,7 +610,7 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
576 if !d.applyHostTheme(sess, ac, t, false) {610 if !d.applyHostTheme(sess, ac, t, false) {
577 return611 return
578 }612 }
579- d.paint(sess, ac, true)613+ d.invalidateRender(sess, ac, true, "client.go")
580 }614 }
581 615
582 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {616 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
@@ -603,6 +637,10 @@ func (d *Daemon) resizeWithPaint(sess *session, ac *attachedClient, sz domain.Si
603 return637 return
604 }638 }
605 ac.size = sz639 ac.size = sz
640+ if rc := sess.renderCoordinator(); rc != nil {
641+ rc.attach(ac)
642+ rc.recordResizeRequest(sz, ac)
643+ }
606 }644 }
607 tbSize := tabSize(sz)645 tbSize := tabSize(sz)
608 646
@@ -637,6 +675,9 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
637 }675 }
638 if sess.detachIfCurrent(ac) {676 if sess.detachIfCurrent(ac) {
639 ac.cancelResizePaint()677 ac.cancelResizePaint()
678+ if rc := sess.renderCoordinator(); rc != nil {
679+ rc.noteDetach(ac)
680+ }
640 d.unregisterPreview(ac)681 d.unregisterPreview(ac)
641 if d.parkAttachment(sess, ac) {682 if d.parkAttachment(sess, ac) {
642 _ = ac.closeCapturedTransport(failed)683 _ = ac.closeCapturedTransport(failed)
M internal/usecase/daemon/clipboard.go +1 -1
@@ -1,6 +1,6 @@
1 // Package daemon holds vev's server-side session multiplexer use case: the1 // Package daemon holds vev's server-side session multiplexer use case: the
2 // accept loop, the ephemeral/named session registry, the per-tab PTY reader2 // accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.3+// and VT screen, and the per-client debounced render coordinator.
4 package daemon4 package daemon
5 5
6 import (6 import (
M internal/usecase/daemon/copymode.go +9 -32
@@ -1,27 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the1+// Package daemon holds vev's server-side session multiplexer use case.
2-// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.
4-//
5-// Concurrency model (sessions own one or more PTY-backed tabs):
6-//
7-// - Serve runs the accept loop. Each accepted connection is handled by its
8-// own goroutine (handleConn): it reads the first frame and routes it to a
9-// session create/attach, a list, or a kill.
10-// - Per session there are exactly two long-lived goroutines: the PTY reader
11-// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12-// and the render scheduler (debounces dirties and paints the attached
13-// client). Both are tied to the session context and unwind when the
14-// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15-// scheduler).
16-// - The daemon exits (Serve returns) when the last session is removed, or
17-// when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).
19-//
20-// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25 package daemon2 package daemon
26 3
27 import (4 import (
@@ -62,7 +39,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
62 if delta > 0 && rt.copyMode.AtBottom(*document) {39 if delta > 0 && rt.copyMode.AtBottom(*document) {
63 rt.clearCopyModeLocked()40 rt.clearCopyModeLocked()
64 rt.copyMu.Unlock()41 rt.copyMu.Unlock()
65- d.paint(sess, ac, true)42+ d.invalidateRender(sess, ac, true, "copymode.go")
66 return43 return
67 }44 }
68 rt.copyMode.Move(*document, delta)45 rt.copyMode.Move(*document, delta)
@@ -72,7 +49,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
72 }49 }
73 rt.copyMu.Unlock()50 rt.copyMu.Unlock()
74 51
75- d.paint(sess, ac, true)52+ d.invalidateRender(sess, ac, true, "copymode.go")
76 }53 }
77 54
78 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {55 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
@@ -94,7 +71,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
94 if !d.publishCopyMode(sess, ac, tb, p, document, nil) {71 if !d.publishCopyMode(sess, ac, tb, p, document, nil) {
95 return72 return
96 }73 }
97- d.paint(sess, ac, true)74+ d.invalidateRender(sess, ac, true, "copymode.go")
98 }75 }
99 76
100 // publishCopyMode installs a non-renderable candidate, validates its captured77 // publishCopyMode installs a non-renderable candidate, validates its captured
@@ -190,7 +167,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
190 rt.copyMu.Unlock()167 rt.copyMu.Unlock()
191 168
192 if changed {169 if changed {
193- d.paint(sess, ac, true)170+ d.invalidateRender(sess, ac, true, "copymode.go")
194 }171 }
195 }172 }
196 173
@@ -220,7 +197,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
220 changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)197 changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)
221 rt.copyMu.Unlock()198 rt.copyMu.Unlock()
222 if changed || closeSearch || accepted {199 if changed || closeSearch || accepted {
223- d.paint(sess, ac, true)200+ d.invalidateRender(sess, ac, true, "copymode.go")
224 }201 }
225 return202 return
226 }203 }
@@ -309,11 +286,11 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
309 rt.copyMu.Unlock()286 rt.copyMu.Unlock()
310 }287 }
311 if exit {288 if exit {
312- d.paint(sess, ac, true)289+ d.invalidateRender(sess, ac, true, "copymode.go")
313 return290 return
314 }291 }
315 if changed {292 if changed {
316- d.paint(sess, ac, true)293+ d.invalidateRender(sess, ac, true, "copymode.go")
317 }294 }
318 }295 }
319 296
@@ -386,7 +363,7 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
386 rt.copyMu.Unlock()363 rt.copyMu.Unlock()
387 364
388 if sess := ac.currentSession(); sess != nil {365 if sess := ac.currentSession(); sess != nil {
389- d.paint(sess, ac, true)366+ d.invalidateRender(sess, ac, true, "copymode.go")
390 }367 }
391 })368 })
392 }369 }
M internal/usecase/daemon/copymode_test.go +36 -3
@@ -385,11 +385,44 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
385 p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe()385 p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe()
386 p.EXPECT().Pid().Return(4242).Maybe()386 p.EXPECT().Pid().Return(4242).Maybe()
387 387
388- d := newTestDaemon(t, newFactory(t, p), stubClock{})388+ // Rendering is coordinator-driven: advance a controllable clock through the
389+ // retained resize timer and its resulting coordinator wake rather than
390+ // relying on wall-clock debounce delivery.
391+ clk := newCoordinatorMockClock(t, 16)
392+ d := newTestDaemon(t, newFactory(t, p), clk.clock)
389 tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5}))393 tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5}))
394+ advanceRender := func() {
395+ deadline := time.NewTimer(2 * time.Second)
396+ defer deadline.Stop()
397+ select {
398+ case timer := <-clk.timers:
399+ timer.ch <- time.Time{}
400+ case <-deadline.C:
401+ t.Fatal("coordinator did not arm a controllable render timer")
402+ }
403+ }
404+ awaitControlledOutput := func() ports.Frame {
405+ deadline := time.NewTimer(2 * time.Second)
406+ defer deadline.Stop()
407+ for {
408+ select {
409+ case frame := <-sends:
410+ if frame.Type == ports.MsgOutput {
411+ return frame
412+ }
413+ t.Fatalf("unexpected frame type %d while advancing render clock", frame.Type)
414+ case timer := <-clk.timers:
415+ timer.ch <- time.Time{}
416+ case <-deadline.C:
417+ t.Fatal("controllable timers did not produce an output frame")
418+ return ports.Frame{}
419+ }
420+ }
421+ }
390 var hg sync.WaitGroup422 var hg sync.WaitGroup
391 hg.Go(func() { d.handleConn(tr) })423 hg.Go(func() { d.handleConn(tr) })
392 awaitFrame(t, sends, ports.MsgWelcome)424 awaitFrame(t, sends, ports.MsgWelcome)
425+ advanceRender() // initial coordinator invalidation
393 awaitFrame(t, sends, ports.MsgOutput)426 awaitFrame(t, sends, ports.MsgOutput)
394 427
395 for i := range 12 {428 for i := range 12 {
@@ -420,9 +453,9 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
420 ac := sess.client453 ac := sess.client
421 require.NotNil(t, ac)454 require.NotNil(t, ac)
422 d.handleInput(sess, ac, []byte("\x1b "))455 d.handleInput(sess, ac, []byte("\x1b "))
423- awaitFrame(t, sends, ports.MsgOutput)456+ awaitControlledOutput()
424 d.handleInput(sess, ac, []byte("VIS\r"))457 d.handleInput(sess, ac, []byte("VIS\r"))
425- awaitFrame(t, sends, ports.MsgOutput)458+ awaitControlledOutput()
426 d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})459 d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})
427 460
428 var payload string461 var payload string
M internal/usecase/daemon/daemon.go +9 -29
@@ -1,29 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the1+// Package daemon holds vev's server-side session multiplexer use case.
2-// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.
4-//
5-// Concurrency model (sessions own one or more PTY-backed tabs):
6-//
7-// - Serve runs the accept loop. Each accepted connection is handled by its
8-// own goroutine (handleConn): it reads the first frame and routes it to a
9-// session create/attach, a list, or a kill.
10-// - Per session there are exactly two long-lived goroutines: the PTY reader
11-// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12-// and the render scheduler (debounces dirties and paints the attached
13-// client). Both are tied to the session context and unwind when the
14-// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15-// scheduler).
16-// - The daemon exits (Serve returns) when the last session is removed, or
17-// when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown). Client detach does not end
19-// a session: ephemeral sessions stay in memory until explicitly killed or
20-// until the daemon exits, and named sessions can also persist on disk.
21-//
22-// Locking: a pane's screen/scrollback and per-client renderer shadow are
23-// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
24-// session.mu; the registry by Daemon.mu. When more than one is held the order
25-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
26-// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
27 package daemon2 package daemon
28 3
29 import (4 import (
@@ -48,7 +23,6 @@ import (
48 const (23 const (
49 minDebounceInterval = 2 * time.Millisecond24 minDebounceInterval = 2 * time.Millisecond
50 maxDebounceInterval = 16 * time.Millisecond25 maxDebounceInterval = 16 * time.Millisecond
51- debounceStep = 2 * time.Millisecond
52 maxSyncUpdateDuration = 500 * time.Millisecond26 maxSyncUpdateDuration = 500 * time.Millisecond
53 )27 )
54 28
@@ -160,7 +134,7 @@ type Daemon struct {
160 done chan struct{}134 done chan struct{}
161 doneOnce sync.Once135 doneOnce sync.Once
162 136
163- sessWg sync.WaitGroup // PTY reader + scheduler goroutines137+ sessWg sync.WaitGroup // PTY reader goroutines
164 connWg sync.WaitGroup // per-connection handler goroutines138 connWg sync.WaitGroup // per-connection handler goroutines
165 }139 }
166 140
@@ -425,7 +399,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
425 // remaining parked connections, so graceful notices are never raced by the399 // remaining parked connections, so graceful notices are never raced by the
426 // force-close. Finally drain the conn handlers, run one defensive sweep in400 // force-close. Finally drain the conn handlers, run one defensive sweep in
427 // case any ordering ever leaves a session behind, and join the session401 // case any ordering ever leaves a session behind, and join the session
428- // goroutines (readers unblock via pty.Close, schedulers via ctx cancel).402+ // goroutines (readers unblock via pty.Close, coordinators via ctx cancel).
429 d.shutdownAll(ports.ReasonServerShutdown)403 d.shutdownAll(ports.ReasonServerShutdown)
430 d.waitNotifies()404 d.waitNotifies()
431 d.hardCancel()405 d.hardCancel()
@@ -624,6 +598,12 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
624 d.clientGone(sess, ac, tr, false)598 d.clientGone(sess, ac, tr, false)
625 return599 return
626 }600 }
601+ if rc := sess.renderCoordinator(); rc == nil || !rc.markAttachmentReady(ac) {
602+ // The attachment was displaced or detached while Welcome was in flight;
603+ // never let this stale handshake emit an Output frame.
604+ d.clientGone(sess, ac, tr, false)
605+ return
606+ }
627 d.firstPaint(sess, ac, h.Size)607 d.firstPaint(sess, ac, h.Size)
628 d.runConnLoop(ac)608 d.runConnLoop(ac)
629 _ = tr.Close()609 _ = tr.Close()
M internal/usecase/daemon/daemon_test.go +87 -0
@@ -286,6 +286,47 @@ func listSessions(t *testing.T, d *Daemon) ports.Sessions {
286 return sessions286 return sessions
287 }287 }
288 288
289+// welcomeBlockingTransport holds the route-level handshake precisely between
290+// attachment publication and Welcome acceptance. It uses the generated
291+// transport mock while channels make both sides of the wire deterministic.
292+type welcomeBlockingTransport struct {
293+ tr *portsmocks.MockTransport
294+ sends chan ports.Frame
295+ welcomeEntered chan struct{}
296+ releaseWelcome chan struct{}
297+ recvDone chan struct{}
298+ closeOnce sync.Once
299+}
300+
301+func newWelcomeBlockingTransport(t *testing.T) *welcomeBlockingTransport {
302+ t.Helper()
303+ b := &welcomeBlockingTransport{
304+ sends: make(chan ports.Frame, 8),
305+ welcomeEntered: make(chan struct{}),
306+ releaseWelcome: make(chan struct{}),
307+ recvDone: make(chan struct{}),
308+ }
309+ b.tr = portsmocks.NewMockTransport(t)
310+ b.tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
311+ b.sends <- f
312+ if f.Type == ports.MsgWelcome {
313+ close(b.welcomeEntered)
314+ <-b.releaseWelcome
315+ }
316+ return nil
317+ }).Maybe()
318+ b.tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
319+ <-b.recvDone
320+ return ports.Frame{}, io.EOF
321+ }).Maybe()
322+ b.tr.EXPECT().Close().Return(nil).Maybe()
323+ return b
324+}
325+
326+func (b *welcomeBlockingTransport) finish() {
327+ b.closeOnce.Do(func() { close(b.recvDone) })
328+}
329+
289 func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports.Frame) {330 func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports.Frame) {
290 t.Helper()331 t.Helper()
291 tr := portsmocks.NewMockTransport(t)332 tr := portsmocks.NewMockTransport(t)
@@ -395,6 +436,52 @@ func (r realTimer) C() <-chan time.Time { return r.t.C }
395 func (r realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }436 func (r realTimer) Reset(d time.Duration) bool { return r.t.Reset(d) }
396 func (r realTimer) Stop() bool { return r.t.Stop() }437 func (r realTimer) Stop() bool { return r.t.Stop() }
397 438
439+func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) {
440+ pty, releasePTY := newBlockingPTY(t)
441+ defer releasePTY()
442+ clock := newCoordinatorMockClock(t, 4)
443+ d := newTestDaemon(t, newFactory(t, pty), clock.clock)
444+ tr := newWelcomeBlockingTransport(t)
445+ done := make(chan struct{})
446+ go func() {
447+ d.handleHello(tr.tr, mustHello(ports.IntentNew, "welcome-gate", domain.Size{Cols: 80, Rows: 24}))
448+ close(done)
449+ }()
450+
451+ <-tr.welcomeEntered
452+ welcome := <-tr.sends
453+ require.Equal(t, ports.MsgWelcome, welcome.Type)
454+ sess := firstSession(d)
455+ sess.mu.Lock()
456+ ac := sess.client
457+ sess.mu.Unlock()
458+ require.NotNil(t, ac, "route must publish the attachment before Welcome returns")
459+
460+ // Exercise the real producer fan-in after publication. The deadline worker
461+ // has completed before asserting the gate, so this is not a timing race.
462+ d.invalidateRender(sess, ac, true, "welcome-gate-test")
463+ // Attach-time theme setup may have armed an older bulk timer. The fresh
464+ // urgent producer replaces it, so advance the newest published deadline.
465+ timer := awaitLatestCoordinatorTimer(t, clock)
466+ rc := sess.renderCoordinator()
467+ rc.mu.Lock()
468+ workerDone := rc.normalWorkerDone
469+ rc.mu.Unlock()
470+ require.NotNil(t, workerDone)
471+ timer.ch <- time.Time{}
472+ awaitHandshakeWorker(t, workerDone)
473+ requireNoCoordinatorOutputFrame(t, tr.sends)
474+
475+ close(tr.releaseWelcome)
476+ output := awaitFrame(t, tr.sends, ports.MsgOutput)
477+ first, err := ports.UnmarshalOutput(output.Payload)
478+ require.NoError(t, err)
479+ require.Zero(t, first.BaseStateNum, "the first post-Welcome frame is full")
480+ tr.finish()
481+ <-done
482+ requireNoCoordinatorOutputFrame(t, tr.sends)
483+}
484+
398 // TestServeReturnsDespiteWedgedClientOnShutdown: killSession's teardown must485 // TestServeReturnsDespiteWedgedClientOnShutdown: killSession's teardown must
399 // not be gated behind the Detached notice. The client transport wedges every486 // not be gated behind the Detached notice. The client transport wedges every
400 // MsgDetached send until the transport is closed (mirroring a full kernel487 // MsgDetached send until the transport is closed (mirroring a full kernel
M internal/usecase/daemon/floating.go +6 -6
@@ -192,7 +192,7 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
192 if start {192 if start {
193 d.launchFloating(sess, tb, cfg, generation, true)193 d.launchFloating(sess, tb, cfg, generation, true)
194 if ac != nil {194 if ac != nil {
195- d.paint(sess, ac, true)195+ d.invalidateRender(sess, ac, true, "floating.go")
196 }196 }
197 return nil197 return nil
198 }198 }
@@ -200,7 +200,7 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
200 d.resizeActiveFloating(tb)200 d.resizeActiveFloating(tb)
201 }201 }
202 if ac != nil {202 if ac != nil {
203- d.paint(sess, ac, true)203+ d.invalidateRender(sess, ac, true, "floating.go")
204 }204 }
205 return nil205 return nil
206 }206 }
@@ -233,7 +233,7 @@ type floatingLaunchSpec struct {
233 }233 }
234 234
235 // launchFloating snapshots launch inputs, then accounts for a worker through235 // launchFloating snapshots launch inputs, then accounts for a worker through
236-// Open, pane initialization, publication, and reader/scheduler startup.236+// Open, pane initialization, publication, and reader/coordinator startup.
237 func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) {237 func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) {
238 spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, userOpen)238 spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, userOpen)
239 if err != nil {239 if err != nil {
@@ -241,7 +241,7 @@ func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfi
241 return241 return
242 }242 }
243 // Count the launch before its goroutine starts. It remains counted through243 // Count the launch before its goroutine starts. It remains counted through
244- // install, including the reader/scheduler Adds, so shutdown cannot return244+ // install, including the reader/coordinator Adds, so shutdown cannot return
245 // before a late Open completion has either been rejected or fully joined.245 // before a late Open completion has either been rejected or fully joined.
246 d.sessWg.Go(func() { d.openAndInstallFloating(sess, tb, spec, generation) })246 d.sessWg.Go(func() { d.openAndInstallFloating(sess, tb, spec, generation) })
247 }247 }
@@ -348,7 +348,7 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
348 ac := sess.client348 ac := sess.client
349 sess.mu.Unlock()349 sess.mu.Unlock()
350 if ac != nil {350 if ac != nil {
351- d.paint(sess, ac, true)351+ d.invalidateRender(sess, ac, true, "floating.go")
352 }352 }
353 }353 }
354 }354 }
@@ -369,7 +369,7 @@ func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64
369 sess.mu.Unlock()369 sess.mu.Unlock()
370 copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)370 copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
371 if ac != nil && (visible || copyCleared) {371 if ac != nil && (visible || copyCleared) {
372- d.paint(sess, ac, true)372+ d.invalidateRender(sess, ac, true, "floating.go")
373 }373 }
374 }374 }
375 375
M internal/usecase/daemon/floating_render_test.go +0 -35
@@ -268,41 +268,6 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
268 }268 }
269 }269 }
270 270
271-func TestRenderHiddenFloatingSkipsPreviewAndActiveClientAndClearsDamage(t *testing.T) {
272- normalPTY, releaseNormal := newBlockingPTY(t)
273- defer releaseNormal()
274- d, sess, _, activeSends := newManualSessionWithPTYs(t, normalPTY)
275- previewTransport, previewSends := newCapturingTransport(t)
276- preview := &attachedClient{tr: previewTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
277- preview.initOverlays()
278- preview.setSession(sess)
279-
280- tb := sess.activeTab()
281- floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 8})
282- floating.screen.Write([]byte("hidden output"))
283- tb.mu.Lock()
284- generation := tb.beginFloatingWarmLocked(false)
285- require.True(t, tb.installFloatingLocked(floating, generation))
286- tb.previewClient = preview
287- tb.mu.Unlock()
288-
289- d.render(sess, tb, floating)
290-
291- floating.mu.Lock()
292- require.Empty(t, floating.screen.Damage())
293- floating.mu.Unlock()
294- select {
295- case frame := <-previewSends:
296- t.Fatalf("hidden floating output painted picker preview: %#v", frame)
297- default:
298- }
299- select {
300- case frame := <-activeSends:
301- t.Fatalf("hidden floating output painted active client: %#v", frame)
302- default:
303- }
304-}
305-
306 func BenchmarkComposeFloatingFrameCached(b *testing.B) {271 func BenchmarkComposeFloatingFrameCached(b *testing.B) {
307 p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})272 p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
308 p.title.displayFallback = "float"273 p.title.displayFallback = "float"
M internal/usecase/daemon/input.go +6 -29
@@ -1,27 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the1+// Package daemon holds vev's server-side session multiplexer use case.
2-// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.
4-//
5-// Concurrency model (sessions own one or more PTY-backed tabs):
6-//
7-// - Serve runs the accept loop. Each accepted connection is handled by its
8-// own goroutine (handleConn): it reads the first frame and routes it to a
9-// session create/attach, a list, or a kill.
10-// - Per session there are exactly two long-lived goroutines: the PTY reader
11-// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12-// and the render scheduler (debounces dirties and paints the attached
13-// client). Both are tied to the session context and unwind when the
14-// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15-// scheduler).
16-// - The daemon exits (Serve returns) when the last session is removed, or
17-// when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).
19-//
20-// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25 package daemon2 package daemon
26 3
27 import (4 import (
@@ -118,7 +95,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
118 if target.ID != oldFocus {95 if target.ID != oldFocus {
119 d.refreshPaneTitleOnFocus(sess, target.ID)96 d.refreshPaneTitleOnFocus(sess, target.ID)
120 }97 }
121- d.paint(sess, ac, true)98+ d.invalidateRender(sess, ac, true, "input.go")
122 return99 return
123 }100 }
124 }101 }
@@ -174,7 +151,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
174 d.exitCopyMode(ac)151 d.exitCopyMode(ac)
175 d.refreshPaneTitleOnFocus(sess, pl.ID)152 d.refreshPaneTitleOnFocus(sess, pl.ID)
176 }153 }
177- d.paint(sess, ac, true)154+ d.invalidateRender(sess, ac, true, "input.go")
178 return155 return
179 }156 }
180 var p *pane157 var p *pane
@@ -195,7 +172,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
195 if isMouseFocusPress(ev) && pl.ID != oldFocus {172 if isMouseFocusPress(ev) && pl.ID != oldFocus {
196 d.exitCopyMode(ac)173 d.exitCopyMode(ac)
197 d.refreshPaneTitleOnFocus(sess, pl.ID)174 d.refreshPaneTitleOnFocus(sess, pl.ID)
198- d.paint(sess, ac, true)175+ d.invalidateRender(sess, ac, true, "input.go")
199 }176 }
200 if multi {177 if multi {
201 ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)178 ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
@@ -290,7 +267,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
290 rt.copyDragging = true267 rt.copyDragging = true
291 }268 }
292 rt.copyMu.Unlock()269 rt.copyMu.Unlock()
293- d.paint(sess, ac, true)270+ d.invalidateRender(sess, ac, true, "input.go")
294 case mouse.Release:271 case mouse.Release:
295 rt.copyMu.Lock()272 rt.copyMu.Lock()
296 rt.normalMousePressValid = false273 rt.normalMousePressValid = false
@@ -432,7 +409,7 @@ func (h daemonKeyHandler) Action(action keys.Action) {
432 idx := int(action - keys.ActionSwitchTab1)409 idx := int(action - keys.ActionSwitchTab1)
433 if sess.switchTab(idx) {410 if sess.switchTab(idx) {
434 h.d.activateTab(sess, sess.activeTab())411 h.d.activateTab(sess, sess.activeTab())
435- h.d.paint(sess, h.ac, true)412+ h.d.invalidateRender(sess, h.ac, true, "input.go")
436 }413 }
437 }414 }
438 }415 }
M internal/usecase/daemon/input_test.go +39 -0
@@ -466,6 +466,45 @@ func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
466 require.Same(t, updated, ac.previousSession.Get())466 require.Same(t, updated, ac.previousSession.Get())
467 }467 }
468 468
469+// backSession's invalid-target fallback is a render producer: it must publish
470+// exactly one coordinator invalidation instead of composing directly, while
471+// the valid-target hand-off (TestPaletteBackSessionTogglesPreviousSession) and
472+// the guarded stale clear (TestStaleBackSessionClearPreservesConcurrentTarget)
473+// keep their PR #73 behavior unchanged.
474+func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) {
475+ for _, tc := range []struct {
476+ name string
477+ prepare func(current *session, ac *attachedClient)
478+ }{
479+ {name: "no previous target", prepare: func(*session, *attachedClient) {}},
480+ {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) {
481+ ac.previousSession.Set(&session{id: "gone"})
482+ }},
483+ {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {
484+ ac.previousSession.Set(current)
485+ }},
486+ } {
487+ t.Run(tc.name, func(t *testing.T) {
488+ d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
489+ defer releaseAll(releases)
490+ invs := make(chan renderInvalidation, 4)
491+ current.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
492+ clock: d.clock,
493+ wake: func(renderWake) {},
494+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
495+ }))
496+ tc.prepare(current, ac)
497+
498+ d.backSession(current, ac)
499+
500+ require.Same(t, current, ac.currentSession(), "an invalid target must not move the attachment")
501+ awaitInvalidation(t, invs)
502+ requireNoInvalidation(t, invs)
503+ requireNoOutputFrame(t, sends)
504+ })
505+ }
506+}
507+
469 func TestSwitchSourcePreviousSessionContracts(t *testing.T) {508 func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
470 for _, tc := range []struct {509 for _, tc := range []struct {
471 name string510 name string
M internal/usecase/daemon/output_state_stream.go +0 -29
@@ -17,8 +17,6 @@ type outputStateStream struct {
17 renderer *renderer.Renderer17 renderer *renderer.Renderer
18 next uint6418 next uint64
19 acked uint6419 acked uint64
20- deferred bool
21- deferredReset bool
22 maxOutstanding uint6420 maxOutstanding uint64
23 }21 }
24 22
@@ -89,37 +87,10 @@ func (s *outputStateStream) ack(state uint64) {
89 func (s *outputStateStream) rebase() {87 func (s *outputStateStream) rebase() {
90 s.acked = s.next88 s.acked = s.next
91 s.renderer.Reset()89 s.renderer.Reset()
92- s.clearDeferred()
93 }90 }
94 91
95 func (s *outputStateStream) atCapacity() bool {92 func (s *outputStateStream) atCapacity() bool {
96 return s.outstanding() >= s.maxOutstanding93 return s.outstanding() >= s.maxOutstanding
97 }94 }
98 95
99-func (s *outputStateStream) deferIfAtCapacity(reset bool) bool {
100- if !s.atCapacity() {
101- if reset {
102- s.clearDeferred()
103- }
104- return false
105- }
106- s.deferred = true
107- s.deferredReset = s.deferredReset || reset
108- return true
109-}
110-
111-func (s *outputStateStream) takeDeferred() (reset bool, ok bool) {
112- if !s.deferred || s.atCapacity() {
113- return false, false
114- }
115- reset = s.deferredReset
116- s.clearDeferred()
117- return reset, true
118-}
119-
120-func (s *outputStateStream) clearDeferred() {
121- s.deferred = false
122- s.deferredReset = false
123-}
124-
125 func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked }96 func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked }
M internal/usecase/daemon/output_state_stream_test.go +0 -75
@@ -68,53 +68,6 @@ func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T
68 }68 }
69 }69 }
70 70
71-func TestOutputStateStreamAckProgressionReleasesDeferredPaint(t *testing.T) {
72- stream := newOutputStateStream()
73- for range maxUnackedOutputStates {
74- stream.frame([]byte("output"), false, 0)
75- }
76- require.True(t, stream.deferIfAtCapacity(false))
77- _, ok := stream.takeDeferred()
78- require.False(t, ok)
79- stream.ack(maxUnackedOutputStates - 1)
80- reset, ok := stream.takeDeferred()
81- require.True(t, ok)
82- require.False(t, reset)
83- require.Equal(t, uint64(1), stream.outstanding())
84-
85- // Stale and future ACKs do not move the cumulative acknowledgement.
86- stream.ack(maxUnackedOutputStates - 2)
87- stream.ack(maxUnackedOutputStates + 1)
88- require.Equal(t, uint64(maxUnackedOutputStates-1), stream.acked)
89-}
90-
91-func TestOutputStateStreamResetIsDependencyFreeAndClearsDeferral(t *testing.T) {
92- stream := newOutputStateStream()
93- for range maxUnackedOutputStates {
94- stream.frame([]byte("output"), false, 0)
95- }
96- require.True(t, stream.deferIfAtCapacity(false))
97- for range 32 {
98- require.True(t, stream.deferIfAtCapacity(true), "reset paints must coalesce while the stream is at capacity")
99- }
100- require.Equal(t, uint64(maxUnackedOutputStates), stream.outstanding())
101- stream.ack(maxUnackedOutputStates)
102- reset, ok := stream.takeDeferred()
103- require.True(t, ok)
104- require.True(t, reset, "a coalesced reset must flush as a dependency-free repaint")
105-
106- frame := renderer.NewFrame(2, 1)
107- fillOutputStateRows(frame, []string{"ok"})
108- outFrame, ok, err := drawOutputState(t, stream, frame, nil, true, 0)
109- require.NoError(t, err)
110- require.True(t, ok)
111- out, err := ports.UnmarshalOutput(outFrame.Payload)
112- require.NoError(t, err)
113- require.Zero(t, out.BaseStateNum)
114- _, ok = stream.takeDeferred()
115- require.False(t, ok)
116-}
117-
118 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {71 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {
119 t.Helper()72 t.Helper()
120 data, err := stream.render(frame, damage, reset)73 data, err := stream.render(frame, damage, reset)
@@ -124,34 +77,6 @@ func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Fra
124 return stream.frame(data, reset, echoAck), true, nil77 return stream.frame(data, reset, echoAck), true, nil
125 }78 }
126 79
127-func TestOutputStateStreamWindowOneCoalescesUntilCumulativeAck(t *testing.T) {
128- stream := newOutputStateStream(1)
129- stream.frame([]byte("first"), true, 0)
130- for range 100 {
131- require.True(t, stream.deferIfAtCapacity(false))
132- }
133- require.Equal(t, uint64(1), stream.outstanding())
134-
135- // A stale ACK cannot release the coalesced paint; the cumulative ACK for
136- // the actually sent state does.
137- stream.ack(0)
138- _, ok := stream.takeDeferred()
139- require.False(t, ok)
140- stream.ack(1)
141- reset, ok := stream.takeDeferred()
142- require.True(t, ok)
143- require.False(t, reset)
144- stream.frame([]byte("latest"), reset, 0)
145- require.Equal(t, uint64(1), stream.outstanding())
146-
147- sideEffect := stream.sideEffect([]byte("reliable"), 0)
148- output, err := ports.UnmarshalOutput(sideEffect.Payload)
149- require.NoError(t, err)
150- require.Zero(t, output.BaseStateNum)
151- require.Zero(t, output.NewStateNum)
152- require.Equal(t, uint64(2), stream.next)
153-}
154-
155 func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) {80 func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) {
156 require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding)81 require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding)
157 require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding)82 require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding)
M internal/usecase/daemon/output_state_sync_test.go +3 -2
@@ -378,6 +378,7 @@ func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) {
378 MaxOutputInFlight: 1,378 MaxOutputInFlight: 1,
379 }, tr)379 }, tr)
380 require.NoError(t, err)380 require.NoError(t, err)
381+ require.True(t, sess.renderCoordinator().markAttachmentReady(ac))
381 382
382 pane := sess.tabs[0].focusedPane()383 pane := sess.tabs[0].focusedPane()
383 pane.screen.Write([]byte("A"))384 pane.screen.Write([]byte("A"))
@@ -388,10 +389,10 @@ func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) {
388 require.Equal(t, uint64(1), firstOutput.NewStateNum)389 require.Equal(t, uint64(1), firstOutput.NewStateNum)
389 for range 100 {390 for range 100 {
390 pane.screen.Write([]byte("x"))391 pane.screen.Write([]byte("x"))
391- d.paint(sess, ac, false)392+ d.invalidateRender(sess, ac, false, "output_state_sync_test.go")
392 }393 }
393 pane.screen.Write([]byte("LATEST"))394 pane.screen.Write([]byte("LATEST"))
394- d.paint(sess, ac, false)395+ d.invalidateRender(sess, ac, false, "output_state_sync_test.go")
395 requireNoOutputFrame(t, tr.sends)396 requireNoOutputFrame(t, tr.sends)
396 require.Equal(t, uint64(1), ac.output.outstanding(), "only one state-bearing datagram output may be unacknowledged")397 require.Equal(t, uint64(1), ac.output.outstanding(), "only one state-bearing datagram output may be unacknowledged")
397 398
M internal/usecase/daemon/overlay_runtime.go +6 -5
@@ -13,11 +13,12 @@ import (
13 type overlayRuntime struct {13 type overlayRuntime struct {
14 ac *attachedClient14 ac *attachedClient
15 15
16- pickerMu sync.Mutex16+ pickerMu sync.Mutex
17- picker *picker.Model17+ picker *picker.Model
18- pickerPreview *tab18+ pickerPreview *tab
19- pickerPending []byte19+ pickerPreviewSession *session
20- pickerESC pendingByteTimer20+ pickerPending []byte
21+ pickerESC pendingByteTimer
21 22
22 paletteMu sync.Mutex23 paletteMu sync.Mutex
23 palette *palette.Model24 palette *palette.Model
M internal/usecase/daemon/paint_gate_test.go +0 -112
@@ -1,14 +1,10 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "io"
5 "testing"4 "testing"
6 "time"5 "time"
7 6
8 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
9- portsmocks "github.com/bnema/vev/internal/ports/mocks"
10- "github.com/bnema/vev/pkg/vt"
11- "github.com/stretchr/testify/require"
12 )8 )
13 9
14 func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) {10 func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) {
@@ -25,111 +21,3 @@ func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) {
25 }21 }
26 }22 }
27 }23 }
28-
29-func TestPaintDefersWhenOutputAckLagsAtCap(t *testing.T) {
30- p, _ := newBlockingPTY(t)
31- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
32- sess.tabs[0].focusedPane().screen.Write([]byte("A"))
33-
34- // Client is maxUnackedOutputStates behind (outputAck stays 0).
35- ac.output.next = maxUnackedOutputStates
36- d.paint(sess, ac, false)
37-
38- requireNoOutputFrame(t, sends)
39- ac.sendMu.Lock()
40- deferred := ac.output.deferred
41- ac.sendMu.Unlock()
42- require.True(t, deferred, "diff paint at ack cap must defer")
43-}
44-
45-func TestDeferredPaintFlushesOnceWhenAcksCatchUp(t *testing.T) {
46- p, _ := newBlockingPTY(t)
47- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
48- sess.tabs[0].focusedPane().screen.Write([]byte("A"))
49-
50- ac.output.next = maxUnackedOutputStates
51- d.paint(sess, ac, false)
52- requireNoOutputFrame(t, sends)
53-
54- // Drive a real MsgAck through the connection loop so the flush is exercised
55- // end-to-end: UnmarshalAck → advanceOutputAck → takeDeferredPaint → paint.
56- // One ack frame, then Recv blocks until the test ends.
57- recvCh := make(chan ports.Frame, 1)
58- done := make(chan struct{})
59- t.Cleanup(func() { close(done) })
60- tr, ok := ac.transport().(*portsmocks.MockTransport)
61- require.True(t, ok, "manual session transport must be the capturing mock")
62- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
63- select {
64- case f := <-recvCh:
65- return f, nil
66- case <-done:
67- return ports.Frame{}, io.EOF
68- }
69- }).Maybe()
70- recvCh <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: maxUnackedOutputStates})}
71- go d.runConnLoop(ac)
72-
73- // The cumulative flush paint must be triggered by the MsgAck handler itself
74- // (the renderer shadow only advanced on send, so the skip coalesces here).
75- awaitFrame(t, sends, ports.MsgOutput)
76- requireNoOutputFrame(t, sends)
77- ac.sendMu.Lock()
78- deferred := ac.output.deferred
79- ac.sendMu.Unlock()
80- require.False(t, deferred, "flag must clear after the flush paint")
81-}
82-
83-func TestResetPaintCoalescesAtAckGate(t *testing.T) {
84- p, _ := newBlockingPTY(t)
85- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
86- sess.tabs[0].focusedPane().screen.Write([]byte("A"))
87-
88- // At the cap, repeated reset paints coalesce instead of growing retained
89- // state and transport queues without bound.
90- ac.output.next = maxUnackedOutputStates
91- for range 32 {
92- d.paint(sess, ac, true)
93- }
94-
95- requireNoOutputFrame(t, sends)
96- ac.sendMu.Lock()
97- deferred := ac.output.deferred
98- deferredReset := ac.output.deferredReset
99- retained := ac.output.outstanding()
100- ac.sendMu.Unlock()
101- require.True(t, deferred)
102- require.True(t, deferredReset)
103- require.LessOrEqual(t, retained, uint64(maxUnackedOutputStates))
104-}
105-
106-func TestCoalescedResetFlushesLatestFrameAfterAck(t *testing.T) {
107- p, _ := newBlockingPTY(t)
108- d, sess, ac, sends := newManualSessionWithPTYs(t, p)
109- for range maxUnackedOutputStates {
110- ac.output.frame([]byte("in flight"), false, 0)
111- }
112-
113- pane := sess.tabs[0].focusedPane()
114- var latest byte
115- for i := range 32 {
116- latest = byte('a' + i%26)
117- pane.screen.Write([]byte{'\r', latest})
118- d.paint(sess, ac, true)
119- }
120- requireNoOutputFrame(t, sends)
121- require.Equal(t, uint64(maxUnackedOutputStates), ac.output.outstanding())
122-
123- ac.ackOutputState(maxUnackedOutputStates)
124- reset, ok := ac.takeDeferredPaint()
125- require.True(t, ok)
126- require.True(t, reset)
127- d.paint(sess, ac, reset)
128-
129- client := vt.NewScreen(80, 25)
130- out := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
131- require.Zero(t, out.BaseStateNum)
132- require.Equal(t, rune(latest), client.Frame.At(0, 1).Rune)
133- require.Equal(t, uint64(1), ac.output.outstanding())
134- requireNoOutputFrame(t, sends)
135-}
M internal/usecase/daemon/palette.go +9 -9
@@ -42,7 +42,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
42 ac.overlays.paletteHints = palette.ContextualHints{}42 ac.overlays.paletteHints = palette.ContextualHints{}
43 ac.overlays.palettePending = nil43 ac.overlays.palettePending = nil
44 ac.overlays.paletteMu.Unlock()44 ac.overlays.paletteMu.Unlock()
45- d.paint(sess, ac, true)45+ d.invalidateRender(sess, ac, true, "palette.go")
46 }46 }
47 47
48 func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {48 func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {
@@ -167,12 +167,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
167 }167 }
168 ac.overlays.paletteMu.Unlock()168 ac.overlays.paletteMu.Unlock()
169 if cancel {169 if cancel {
170- d.paint(sess, ac, true)170+ d.invalidateRender(sess, ac, true, "palette.go")
171 return171 return
172 }172 }
173 if !execute {173 if !execute {
174 if changed {174 if changed {
175- d.paint(sess, ac, true)175+ d.invalidateRender(sess, ac, true, "palette.go")
176 }176 }
177 return177 return
178 }178 }
@@ -183,7 +183,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
183 rank, err := command.ParsePositiveDecimal(args)183 rank, err := command.ParsePositiveDecimal(args)
184 if err != nil {184 if err != nil {
185 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")185 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
186- d.paint(sess, ac, true)186+ d.invalidateRender(sess, ac, true, "palette.go")
187 return187 return
188 }188 }
189 // The captured ID is handed off atomically. A target can disappear after189 // The captured ID is handed off atomically. A target can disappear after
@@ -191,14 +191,14 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
191 exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}191 exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}
192 if err := exec.JumpRecentSession(rank); err != nil {192 if err := exec.JumpRecentSession(rank); err != nil {
193 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")193 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
194- d.paint(sess, ac, true)194+ d.invalidateRender(sess, ac, true, "palette.go")
195 return195 return
196 }196 }
197 // Publication remains generation-safe; do not let stale work close a197 // Publication remains generation-safe; do not let stale work close a
198 // newer palette interaction.198 // newer palette interaction.
199 if ac.closeExecutedPalette(generation, rawQuery) {199 if ac.closeExecutedPalette(generation, rawQuery) {
200 d.recordPaletteUse(cmd.Code)200 d.recordPaletteUse(cmd.Code)
201- d.paint(ac.currentSession(), ac, true)201+ d.invalidateRender(ac.currentSession(), ac, true, "palette.go")
202 }202 }
203 return203 return
204 }204 }
@@ -259,7 +259,7 @@ type paletteExec struct {
259 }259 }
260 260
261 func (e paletteExec) CreateTab() error {261 func (e paletteExec) CreateTab() error {
262- defer e.d.paint(e.sess, e.ac, true)262+ defer e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
263 return e.d.createTab(e.sess, e.ac.size)263 return e.d.createTab(e.sess, e.ac.size)
264 }264 }
265 265
@@ -325,7 +325,7 @@ func (e paletteExec) NextTab() error {
325 if e.sess.switchRelative(1) {325 if e.sess.switchRelative(1) {
326 e.d.activateTab(e.sess, e.sess.activeTab())326 e.d.activateTab(e.sess, e.sess.activeTab())
327 }327 }
328- e.d.paint(e.sess, e.ac, true)328+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
329 return nil329 return nil
330 }330 }
331 331
@@ -333,7 +333,7 @@ func (e paletteExec) PrevTab() error {
333 if e.sess.switchRelative(-1) {333 if e.sess.switchRelative(-1) {
334 e.d.activateTab(e.sess, e.sess.activeTab())334 e.d.activateTab(e.sess, e.sess.activeTab())
335 }335 }
336- e.d.paint(e.sess, e.ac, true)336+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
337 return nil337 return nil
338 }338 }
339 339
M internal/usecase/daemon/pane.go +1 -12
@@ -11,7 +11,7 @@ import (
11 "github.com/bnema/vev/pkg/vt"11 "github.com/bnema/vev/pkg/vt"
12 )12 )
13 13
14-// pane owns one PTY, its terminal screen state, and render scheduling channels.14+// pane owns one PTY and its terminal screen state.
15 // Lock order when multiple locks are held is:15 // Lock order when multiple locks are held is:
16 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.16 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
17 // The PTY reader takes only pane.mu, so child output never waits on client IO.17 // The PTY reader takes only pane.mu, so child output never waits on client IO.
@@ -23,8 +23,6 @@ type pane struct {
23 resizeMu sync.Mutex // serializes PTY resizes without holding mu23 resizeMu sync.Mutex // serializes PTY resizes without holding mu
24 screen *vt.Screen24 screen *vt.Screen
25 scrollback *scopy.Scrollback25 scrollback *scopy.Scrollback
26- dirty chan struct{}
27- flush chan struct{}
28 syncGen uint6426 syncGen uint64
29 rect domain.Rect27 rect domain.Rect
30 popupGeometry floatingGeometry // last geometry committed after a successful floating resize28 popupGeometry floatingGeometry // last geometry committed after a successful floating resize
@@ -51,16 +49,7 @@ func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz do
51 pty: pty,49 pty: pty,
52 screen: screen,50 screen: screen,
53 scrollback: sb,51 scrollback: sb,
54- dirty: make(chan struct{}, 1),
55- flush: make(chan struct{}, 1),
56 rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},52 rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
57 title: paneTitleState{displayFallback: "sh"},53 title: paneTitleState{displayFallback: "sh"},
58 }54 }
59 }55 }
60-
61-func paneDone(p *pane) <-chan struct{} {
62- if p.ctx == nil {
63- return nil
64- }
65- return p.ctx.Done()
66-}
M internal/usecase/daemon/pane_actions.go +10 -7
@@ -23,7 +23,7 @@ func (d *Daemon) spawnPaneOp(
23 ) error {23 ) error {
24 if d.ptys == nil {24 if d.ptys == nil {
25 if ac != nil {25 if ac != nil {
26- d.paint(sess, ac, true)26+ d.invalidateRender(sess, ac, true, "pane_actions.go")
27 }27 }
28 return nil28 return nil
29 }29 }
@@ -99,7 +99,7 @@ func (d *Daemon) spawnPaneOp(
99 d.startPaneGoroutines(sess, tb, p)99 d.startPaneGoroutines(sess, tb, p)
100 markSnapshotDirty(sess)100 markSnapshotDirty(sess)
101 if ac != nil {101 if ac != nil {
102- d.paint(sess, ac, true)102+ d.invalidateRender(sess, ac, true, "pane_actions.go")
103 }103 }
104 return nil104 return nil
105 }105 }
@@ -167,7 +167,7 @@ func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
167 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {167 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
168 if d.ptys == nil {168 if d.ptys == nil {
169 if ac != nil {169 if ac != nil {
170- d.paint(sess, ac, true)170+ d.invalidateRender(sess, ac, true, "pane_actions.go")
171 }171 }
172 return nil172 return nil
173 }173 }
@@ -188,7 +188,7 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
188 if err == nil {188 if err == nil {
189 markSnapshotDirty(sess)189 markSnapshotDirty(sess)
190 if ac != nil {190 if ac != nil {
191- d.paint(sess, ac, true)191+ d.invalidateRender(sess, ac, true, "pane_actions.go")
192 }192 }
193 }193 }
194 return err194 return err
@@ -197,7 +197,7 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
197 func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {197 func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
198 if d.ptys == nil {198 if d.ptys == nil {
199 if ac != nil {199 if ac != nil {
200- d.paint(sess, ac, true)200+ d.invalidateRender(sess, ac, true, "pane_actions.go")
201 }201 }
202 return nil202 return nil
203 }203 }
@@ -257,6 +257,9 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
257 ac.overlays.clearCopyModeForPane(p)257 ac.overlays.clearCopyModeForPane(p)
258 }258 }
259 259
260+ if rc := sess.renderCoordinator(); rc != nil {
261+ rc.noteSyncPaneRemoved(p)
262+ }
260 if p.cancel != nil {263 if p.cancel != nil {
261 p.cancel()264 p.cancel()
262 }265 }
@@ -267,7 +270,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
267 markSnapshotDirty(sess)270 markSnapshotDirty(sess)
268 if repaint {271 if repaint {
269 if ac != nil {272 if ac != nil {
270- d.paint(sess, ac, true)273+ d.invalidateRender(sess, ac, true, "pane_actions.go")
271 }274 }
272 }275 }
273 return nil276 return nil
@@ -305,7 +308,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
305 d.refreshPaneTitleOnFocus(sess, newFocus)308 d.refreshPaneTitleOnFocus(sess, newFocus)
306 }309 }
307 }310 }
308- d.paint(sess, ac, true)311+ d.invalidateRender(sess, ac, true, "pane_actions.go")
309 }312 }
310 if errors.Is(err, layout.ErrNoPane) {313 if errors.Is(err, layout.ErrNoPane) {
311 return nil314 return nil
M internal/usecase/daemon/performance_benchmark_test.go +95 -29
@@ -51,6 +51,10 @@ func TestPerformanceFixtureCounters(t *testing.T) {
51 require.Equal(t, uint64(2), metrics.outputFrames)51 require.Equal(t, uint64(2), metrics.outputFrames)
52 require.Positive(t, metrics.outputBytes)52 require.Positive(t, metrics.outputBytes)
53 require.Positive(t, metrics.outputPayloadBytes)53 require.Positive(t, metrics.outputPayloadBytes)
54+ require.Equal(t, uint64(2), metrics.coordinatorInvalidations)
55+ require.Equal(t, uint64(2), metrics.coordinatorWakes)
56+ require.Equal(t, uint64(2), metrics.coordinatorCoalesced)
57+ require.Positive(t, metrics.coordinatorWakes, "coordinator metric ratios require a nonzero denominator")
54 58
55 fixture.captureSnapshot()59 fixture.captureSnapshot()
56 metrics = fixture.metrics()60 metrics = fixture.metrics()
@@ -86,6 +90,35 @@ func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
86 }90 }
87 }91 }
88 92
93+func TestPerformanceFixtureCoordinatorMetricsForCanonicalTopologies(t *testing.T) {
94+ for _, topology := range daemonHistoryTopologies {
95+ t.Run(topology.name, func(t *testing.T) {
96+ fixture := newPerformanceFixture(t, performanceConfig{
97+ size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000,
98+ })
99+ fixture.paintLive()
100+ metrics := fixture.metrics()
101+ require.Equal(t, uint64(1), metrics.coordinatorInvalidations)
102+ require.Equal(t, uint64(1), metrics.coordinatorWakes)
103+ require.Equal(t, uint64(1), metrics.coordinatorCoalesced)
104+ require.Positive(t, metrics.coordinatorWakes, "coalescing ratio denominator")
105+ })
106+ }
107+}
108+
109+type daemonHistoryTopology struct {
110+ name string
111+ tabs, panes int
112+}
113+
114+var daemonHistoryTopologies = []daemonHistoryTopology{
115+ {name: "1tab-1pane-control", tabs: 1, panes: 1},
116+ {name: "1tab-4panes", tabs: 1, panes: 4},
117+ {name: "4tabs-1pane", tabs: 4, panes: 1},
118+ {name: "4tabs-4panes", tabs: 4, panes: 4},
119+ {name: "8tabs-1pane", tabs: 8, panes: 1},
120+}
121+
89 func BenchmarkDaemonHistoryLivePaint(b *testing.B) {122 func BenchmarkDaemonHistoryLivePaint(b *testing.B) {
90 benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() })123 benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() })
91 }124 }
@@ -104,16 +137,7 @@ func BenchmarkDaemonHistoryResize(b *testing.B) {
104 137
105 func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) {138 func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) {
106 b.Helper()139 b.Helper()
107- for _, topology := range []struct {140+ for _, topology := range daemonHistoryTopologies {
108- name string
109- tabs, panes int
110- }{
111- {name: "1tab-1pane-control", tabs: 1, panes: 1},
112- {name: "1tab-4panes", tabs: 1, panes: 4},
113- {name: "4tabs-1pane", tabs: 4, panes: 1},
114- {name: "4tabs-4panes", tabs: 4, panes: 4},
115- {name: "8tabs-1pane", tabs: 8, panes: 1},
116- } {
117 b.Run(topology.name, func(b *testing.B) {141 b.Run(topology.name, func(b *testing.B) {
118 fixture := newPerformanceFixture(b, performanceConfig{142 fixture := newPerformanceFixture(b, performanceConfig{
119 size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000,143 size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000,
@@ -153,6 +177,21 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor
153 }177 }
154 }178 }
155 179
180+func TestCoordinatorCoalescingRatioReportsInvalidationsPerWake(t *testing.T) {
181+ require.Equal(t, float64(3), coordinatorCoalescingRatio(performanceMetrics{
182+ coordinatorWakes: 1,
183+ coordinatorCoalesced: 3,
184+ }))
185+ require.Zero(t, coordinatorCoalescingRatio(performanceMetrics{}))
186+}
187+
188+func coordinatorCoalescingRatio(metrics performanceMetrics) float64 {
189+ if metrics.coordinatorWakes == 0 {
190+ return 0
191+ }
192+ return float64(metrics.coordinatorCoalesced) / float64(metrics.coordinatorWakes)
193+}
194+
156 func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations, historyRows int) {195 func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations, historyRows int) {
157 b.Helper()196 b.Helper()
158 if operations == 0 {197 if operations == 0 {
@@ -164,6 +203,10 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations
164 b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op")203 b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op")
165 b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op")204 b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op")
166 b.ReportMetric(float64(metrics.snapshotBytes)/perOperation, "snapshotbytes/op")205 b.ReportMetric(float64(metrics.snapshotBytes)/perOperation, "snapshotbytes/op")
206+ b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op")
207+ b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op")
208+ b.ReportMetric(float64(metrics.coordinatorCoalesced)/perOperation, "coordinatorcoalesced/op")
209+ b.ReportMetric(coordinatorCoalescingRatio(metrics), "coordinatorcoalescingratio")
167 b.ReportMetric(float64(historyRows), "historyrows/pane")210 b.ReportMetric(float64(historyRows), "historyrows/pane")
168 }211 }
169 212
@@ -192,26 +235,30 @@ type performanceConfig struct {
192 }235 }
193 236
194 type performanceMetrics struct {237 type performanceMetrics struct {
195- outputFrames uint64238+ outputFrames uint64
196- outputBytes uint64239+ outputBytes uint64
197- outputPayloadBytes uint64240+ outputPayloadBytes uint64
198- snapshotWrites uint64241+ snapshotWrites uint64
199- snapshotBytes uint64242+ snapshotBytes uint64
243+ coordinatorInvalidations uint64
244+ coordinatorWakes uint64
245+ coordinatorCoalesced uint64
200 }246 }
201 247
202 type performanceFixture struct {248 type performanceFixture struct {
203- t testing.TB249+ t testing.TB
204- d *Daemon250+ d *Daemon
205- sess *session251+ sess *session
206- ac *attachedClient252+ ac *attachedClient
207- output *countingOutputTransport253+ output *countingOutputTransport
208- snaps *countingSnapshotStore254+ snaps *countingSnapshotStore
209- activePane *pane255+ activePane *pane
210- liveWrites [][]byte256+ liveWrites [][]byte
211- paints int257+ paints int
212- resizeSizes [2]domain.Size258+ resizeSizes [2]domain.Size
213- resizes int259+ resizes int
214- resizedSize domain.Size260+ resizedSize domain.Size
261+ coordinatorBaseline renderCoordinatorBurstMetricsSnapshot
215 }262 }
216 263
217 func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture {264 func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture {
@@ -253,6 +300,11 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
253 }300 }
254 fixture.activePane = fixture.findActivePane()301 fixture.activePane = fixture.findActivePane()
255 302
303+ // The fixture uses the production coordinator. stubClock's inert timer is
304+ // completed synchronously by the coordinator, keeping benchmark operations
305+ // deterministic while retaining their real invalidation path.
306+ d.attachCoordinator(sess, nil, ac, true)
307+
256 // Prime the real renderer shadow before measurements. Subsequent paints use308 // Prime the real renderer shadow before measurements. Subsequent paints use
257 // actual production diffs rather than the initial full frame.309 // actual production diffs rather than the initial full frame.
258 d.paint(sess, ac, true)310 d.paint(sess, ac, true)
@@ -311,7 +363,11 @@ func (f *performanceFixture) paintLive() {
311 p.mu.Lock()363 p.mu.Lock()
312 p.screen.Write(f.liveWrites[f.paints%len(f.liveWrites)])364 p.screen.Write(f.liveWrites[f.paints%len(f.liveWrites)])
313 p.mu.Unlock()365 p.mu.Unlock()
314- f.d.paint(f.sess, f.ac, false)366+ if rc := f.sess.renderCoordinator(); rc != nil {
367+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "performance_benchmark_test.go"})
368+ } else {
369+ f.d.paint(f.sess, f.ac, false)
370+ }
315 f.paints++371 f.paints++
316 }372 }
317 373
@@ -359,15 +415,25 @@ func (f *performanceFixture) resized() bool {
359 func (f *performanceFixture) resetMetrics() {415 func (f *performanceFixture) resetMetrics() {
360 f.output.reset()416 f.output.reset()
361 f.snaps.reset()417 f.snaps.reset()
418+ if rc := f.sess.renderCoordinator(); rc != nil {
419+ f.coordinatorBaseline = rc.burstMetricsSnapshot()
420+ }
362 }421 }
363 422
364 func (f *performanceFixture) metrics() performanceMetrics {423 func (f *performanceFixture) metrics() performanceMetrics {
365 output := f.output.metrics()424 output := f.output.metrics()
366 snapshots := f.snaps.metrics()425 snapshots := f.snaps.metrics()
367- return performanceMetrics{426+ metrics := performanceMetrics{
368 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,427 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,
369 snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,428 snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,
370 }429 }
430+ if rc := f.sess.renderCoordinator(); rc != nil {
431+ coordinator := rc.burstMetricsSnapshot()
432+ metrics.coordinatorInvalidations = coordinator.invalidations - f.coordinatorBaseline.invalidations
433+ metrics.coordinatorWakes = coordinator.wakes - f.coordinatorBaseline.wakes
434+ metrics.coordinatorCoalesced = coordinator.coalesced - f.coordinatorBaseline.coalesced
435+ }
436+ return metrics
371 }437 }
372 438
373 const outputPayloadHeaderBytes = 24439 const outputPayloadHeaderBytes = 24
M internal/usecase/daemon/picker.go +89 -88
@@ -1,27 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the1+// Package daemon holds vev's server-side session multiplexer use case.
2-// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.
4-//
5-// Concurrency model (sessions own one or more PTY-backed tabs):
6-//
7-// - Serve runs the accept loop. Each accepted connection is handled by its
8-// own goroutine (handleConn): it reads the first frame and routes it to a
9-// session create/attach, a list, or a kill.
10-// - Per session there are exactly two long-lived goroutines: the PTY reader
11-// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12-// and the render scheduler (debounces dirties and paints the attached
13-// client). Both are tied to the session context and unwind when the
14-// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15-// scheduler).
16-// - The daemon exits (Serve returns) when the last session is removed, or
17-// when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).
19-//
20-// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25 package daemon2 package daemon
26 3
27 import (4 import (
@@ -47,7 +24,7 @@ func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
47 ac.overlays.pickerPreview = nil24 ac.overlays.pickerPreview = nil
48 ac.overlays.pickerMu.Unlock()25 ac.overlays.pickerMu.Unlock()
49 d.registerPreviewForSelection(ac)26 d.registerPreviewForSelection(ac)
50- d.paint(sess, ac, true)27+ d.invalidateRender(sess, ac, true, "picker.go")
51 }28 }
52 29
53 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {30 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
@@ -141,7 +118,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
141 d.killPickerTarget(target)118 d.killPickerTarget(target)
142 }119 }
143 d.refreshPicker(ac)120 d.refreshPicker(ac)
144- d.paint(sess, ac, true)121+ d.invalidateRender(sess, ac, true, "picker.go")
145 return122 return
146 case 'j':123 case 'j':
147 ac.overlays.picker.Down()124 ac.overlays.picker.Down()
@@ -191,7 +168,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
191 return168 return
192 }169 }
193 if exit || changed {170 if exit || changed {
194- d.paint(sess, ac, true)171+ d.invalidateRender(sess, ac, true, "picker.go")
195 }172 }
196 }173 }
197 174
@@ -211,7 +188,7 @@ func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
211 188
212 d.unregisterPreview(ac)189 d.unregisterPreview(ac)
213 if sess := ac.currentSession(); sess != nil {190 if sess := ac.currentSession(); sess != nil {
214- d.paint(sess, ac, true)191+ d.invalidateRender(sess, ac, true, "picker.go")
215 }192 }
216 })193 })
217 }194 }
@@ -245,83 +222,95 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
245 if ac.overlays.picker != nil {222 if ac.overlays.picker != nil {
246 target, ok = ac.overlays.picker.Selected()223 target, ok = ac.overlays.picker.Selected()
247 }224 }
225+ previous := ac.overlays.pickerPreviewSession
226+ ac.overlays.pickerPreviewSession = nil
227+ ac.overlays.pickerPreview = nil
248 ac.overlays.pickerMu.Unlock()228 ac.overlays.pickerMu.Unlock()
249-229+ if previous != nil {
250- var next *tab230+ if rc := previous.renderCoordinator(); rc != nil {
251- if ok {231+ rc.teardownPreviewFor(ac)
252- next = d.tabByTarget(target)232+ }
233+ }
234+ if !ok {
235+ return
236+ }
237+ next := d.tabByTarget(target)
238+ targetSess := d.sessionByID(target.Session)
239+ if next == nil || targetSess == nil {
240+ return
253 }241 }
254-
255 ac.overlays.pickerMu.Lock()242 ac.overlays.pickerMu.Lock()
256- old := ac.overlays.pickerPreview243+ // Selection may have changed while the target was resolved.
257 if ac.overlays.picker == nil {244 if ac.overlays.picker == nil {
258- next = nil245+ ac.overlays.pickerMu.Unlock()
246+ return
259 }247 }
260 ac.overlays.pickerPreview = next248 ac.overlays.pickerPreview = next
249+ ac.overlays.pickerPreviewSession = targetSess
261 ac.overlays.pickerMu.Unlock()250 ac.overlays.pickerMu.Unlock()
262-251+ // A same-session preview is already invalidated by its own coordinator.
263- if old != nil && old != next {252+ // Cross-session previews subscribe to the target's producer wake instead
264- old.mu.Lock()253+ // of starting a direct paint/timer path.
265- if old.previewClient == ac {254+ if targetSess == ac.currentSession() {
266- old.previewClient = nil255+ return
267- }
268- old.mu.Unlock()
269 }256 }
270- if next != nil {257+ rc := d.attachCoordinator(targetSess, nil, nil, false)
271- next.mu.Lock()258+ rc.subscribePreviewFor(ac, func(renderWake) {
272- next.previewClient = ac
273- next.mu.Unlock()
274-
275 ac.overlays.pickerMu.Lock()259 ac.overlays.pickerMu.Lock()
276- keep := ac.overlays.pickerPreview == next260+ valid := ac.overlays.pickerPreviewSession == targetSess
277 ac.overlays.pickerMu.Unlock()261 ac.overlays.pickerMu.Unlock()
278- if !keep {262+ if valid {
279- next.mu.Lock()263+ if owner := ac.currentSession(); owner != nil {
280- if next.previewClient == ac {264+ d.invalidateRender(owner, ac, false, "picker preview")
281- next.previewClient = nil
282 }265 }
283- next.mu.Unlock()
284 }266 }
285- }267+ })
286 }268 }
287 269
288 func (d *Daemon) unregisterPreview(ac *attachedClient) {270 func (d *Daemon) unregisterPreview(ac *attachedClient) {
271+ if ac == nil || ac.overlays == nil {
272+ return
273+ }
289 ac.overlays.pickerMu.Lock()274 ac.overlays.pickerMu.Lock()
290- old := ac.overlays.pickerPreview275+ previous := ac.overlays.pickerPreviewSession
276+ ac.overlays.pickerPreviewSession = nil
291 ac.overlays.pickerPreview = nil277 ac.overlays.pickerPreview = nil
292 ac.overlays.pickerMu.Unlock()278 ac.overlays.pickerMu.Unlock()
293-279+ if previous != nil {
294- if old != nil {280+ if rc := previous.renderCoordinator(); rc != nil {
295- old.mu.Lock()281+ rc.teardownPreviewFor(ac)
296- if old.previewClient == ac {
297- old.previewClient = nil
298 }282 }
299- old.mu.Unlock()
300 }283 }
301 }284 }
302 285
286+// clearDestroyedTabPreview clears coordinator-owned picker state for clients
287+// that were previewing a removed tab. Tabs do not route render ownership.
303 func (d *Daemon) clearDestroyedTabPreview(tb *tab) {288 func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
304- tb.mu.Lock()289+ if d == nil || d.sessions == nil {
305- previewer := tb.previewClient
306- tb.mu.Unlock()
307- if previewer == nil {
308 return290 return
309 }291 }
310- cleared := false292+ d.mu.Lock()
311- previewer.overlays.pickerMu.Lock()293+ var clients []*attachedClient
312- if previewer.overlays.pickerPreview == tb {294+ for _, sess := range d.sessions {
313- previewer.overlays.pickerPreview = nil295+ sess.mu.Lock()
314- cleared = true296+ if sess.client != nil {
315- }297+ clients = append(clients, sess.client)
316- previewer.overlays.pickerMu.Unlock()298+ }
317- tb.mu.Lock()299+ sess.mu.Unlock()
318- if tb.previewClient == previewer {
319- tb.previewClient = nil
320 }300 }
321- tb.mu.Unlock()301+ d.mu.Unlock()
322- if cleared {302+ for _, ac := range clients {
323- if sess := previewer.currentSession(); sess != nil {303+ if ac == nil || ac.overlays == nil {
324- d.paint(sess, previewer, true)304+ continue
305+ }
306+ ac.overlays.pickerMu.Lock()
307+ cleared := ac.overlays.pickerPreview == tb
308+ ac.overlays.pickerMu.Unlock()
309+ if cleared {
310+ d.unregisterPreview(ac)
311+ if sess := ac.currentSession(); sess != nil {
312+ d.invalidateRender(sess, ac, true, "picker.go")
313+ }
325 }314 }
326 }315 }
327 }316 }
@@ -362,19 +351,19 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
362 }351 }
363 targetSess := d.sessionByID(target.Session)352 targetSess := d.sessionByID(target.Session)
364 if targetSess == nil {353 if targetSess == nil {
365- d.paint(sess, ac, true)354+ d.invalidateRender(sess, ac, true, "picker.go")
366 return false355 return false
367 }356 }
368 if targetSess == sess {357 if targetSess == sess {
369 if sess.switchTab(target.TabIndex) {358 if sess.switchTab(target.TabIndex) {
370 d.activateTab(sess, sess.activeTab())359 d.activateTab(sess, sess.activeTab())
371 }360 }
372- d.paint(sess, ac, true)361+ d.invalidateRender(sess, ac, true, "picker.go")
373 return true362 return true
374 }363 }
375 old := d.stealClientForTarget(sess, ac, targetSess, target)364 old := d.stealClientForTarget(sess, ac, targetSess, target)
376 if ac.currentSession() != targetSess {365 if ac.currentSession() != targetSess {
377- d.paint(sess, ac, true)366+ d.invalidateRender(sess, ac, true, "picker.go")
378 return false367 return false
379 }368 }
380 if old != nil && old != ac {369 if old != nil && old != ac {
@@ -407,16 +396,27 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
407 targetSess.mu.Lock()396 targetSess.mu.Lock()
408 old := targetSess.client397 old := targetSess.client
409 targetSess.terminal = term398 targetSess.terminal = term
410- targetSess.client = ac
411 if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {399 if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
412 targetSess.active = target.TabIndex400 targetSess.active = target.TabIndex
413 }401 }
414 targetSess.mu.Unlock()402 targetSess.mu.Unlock()
415 403
416- d.touchMRU(targetSess)404+ // Do not expose targetSess.client until the target coordinator has claimed
405+ // this attachment and the output dependency chain has been rebased.
406+ d.handoffCoordinator(from, targetSess, old, ac)
417 ac.setSession(targetSess)407 ac.setSession(targetSess)
408+ targetSess.mu.Lock()
409+ targetSess.client = ac
410+ targetSess.mu.Unlock()
411+ d.touchMRU(targetSess)
418 ac.recordPreviousSession(from)412 ac.recordPreviousSession(from)
419 d.mu.Unlock()413 d.mu.Unlock()
414+ if old != nil && old != ac {
415+ // Displacement is a detach lifecycle transition too. Cancel the
416+ // attachment-owned PR #71 timer only after releasing daemon/session
417+ // locks, preserving sendMu > daemon/session lock ordering.
418+ old.cancelResizePaint()
419+ }
420 return old420 return old
421 }421 }
422 422
@@ -425,7 +425,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
425 stopped, ok := d.stopped[target.Name]425 stopped, ok := d.stopped[target.Name]
426 if !ok || stopped.purging {426 if !ok || stopped.purging {
427 d.mu.Unlock()427 d.mu.Unlock()
428- d.paint(from, ac, true)428+ d.invalidateRender(from, ac, true, "picker.go")
429 return false429 return false
430 }430 }
431 from.mu.Lock()431 from.mu.Lock()
@@ -441,18 +441,19 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
441 from.mu.Unlock()441 from.mu.Unlock()
442 d.mu.Unlock()442 d.mu.Unlock()
443 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)443 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
444- d.paint(from, ac, true)444+ d.invalidateRender(from, ac, true, "picker.go")
445 return false445 return false
446 }446 }
447 from.client = nil447 from.client = nil
448 ac.setSession(nil)448 ac.setSession(nil)
449 from.mu.Unlock()449 from.mu.Unlock()
450+
451+ d.handoffCoordinator(from, targetSess, nil, ac)
452+ ac.setSession(targetSess)
450 targetSess.mu.Lock()453 targetSess.mu.Lock()
451 targetSess.client = ac454 targetSess.client = ac
452 targetSess.mu.Unlock()455 targetSess.mu.Unlock()
453-
454 d.touchMRU(targetSess)456 d.touchMRU(targetSess)
455- ac.setSession(targetSess)
456 ac.recordPreviousSession(from)457 ac.recordPreviousSession(from)
457 d.mu.Unlock()458 d.mu.Unlock()
458 d.firstPaint(targetSess, ac, ac.size)459 d.firstPaint(targetSess, ac, ac.size)
M internal/usecase/daemon/picker_test.go +36 -118
@@ -2,7 +2,6 @@ package daemon
2 2
3 import (3 import (
4 "context"4 "context"
5- "sync"
6 "testing"5 "testing"
7 "time"6 "time"
8 7
@@ -259,6 +258,42 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
259 awaitFrame(t, sends1, ports.MsgOutput)258 awaitFrame(t, sends1, ports.MsgOutput)
260 }259 }
261 260
261+func TestPickerDisplacementCancelsOldResizePaint(t *testing.T) {
262+ p1, releasePTY1 := newBlockingPTY(t)
263+ p2, releasePTY2 := newBlockingPTY(t)
264+ defer releasePTY1()
265+ defer releasePTY2()
266+ clock := &signalClock{timers: make(chan *signalTimer, 2)}
267+ d := newTestDaemon(t, nil, clock)
268+ tr1, _ := newCapturingTransport(t)
269+ tr2, _ := newCapturingTransport(t)
270+ ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
271+ ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
272+ sctx1, cancel1 := context.WithCancel(d.serveCtx)
273+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
274+ defer cancel1()
275+ defer cancel2()
276+ sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}
277+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
278+ ac1.setSession(sess1)
279+ ac2.setSession(sess2)
280+ d.sessions[sess1.id], d.sessions[sess2.id] = sess1, sess2
281+
282+ d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24})
283+ timer := <-clock.timers
284+ ac2.sendMu.Lock()
285+ before := ac2.resizePaintGeneration
286+ require.True(t, ac2.resizePaintPending)
287+ ac2.sendMu.Unlock()
288+
289+ require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id}))
290+ ac2.sendMu.Lock()
291+ require.False(t, ac2.resizePaintPending)
292+ require.Equal(t, before+1, ac2.resizePaintGeneration)
293+ ac2.sendMu.Unlock()
294+ timer.ch <- time.Time{}
295+}
296+
262 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {297 func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
263 p1, releasePTY1 := newBlockingPTY(t)298 p1, releasePTY1 := newBlockingPTY(t)
264 p2, releasePTY2 := newBlockingPTY(t)299 p2, releasePTY2 := newBlockingPTY(t)
@@ -448,123 +483,6 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {
448 require.Equal(t, 'R', preview.Rows[2][21].Rune, "expanded stacked pane content should be included")483 require.Equal(t, 'R', preview.Rows[2][21].Rune, "expanded stacked pane content should be included")
449 }484 }
450 485
451-func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) {
452- d, sess, ac, sends, releases := newManualTabSession(t, 2)
453- defer func() {
454- for _, release := range releases {
455- release()
456- }
457- }()
458-
459- d.enterPicker(sess, ac)
460- awaitFrame(t, sends, ports.MsgOutput)
461- d.handleInput(sess, ac, []byte("j"))
462- awaitFrame(t, sends, ports.MsgOutput)
463-
464- previewTab := sess.tabs[1]
465- previewTab.mu.Lock()
466- previewTab.focusedPane().screen.Write([]byte("inactive-preview-live"))
467- previewTab.mu.Unlock()
468- d.render(sess, previewTab, previewTab.focusedPane())
469-
470- previewOut := awaitFrame(t, sends, ports.MsgOutput)
471- previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
472- require.NoError(t, err)
473- require.Contains(t, string(previewMsg.Data), "inactive-preview-live")
474-}
475-
476-func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
477- p1, releasePTY1 := newBlockingPTY(t)
478- p2, releasePTY2 := newBlockingPTY(t)
479- defer releasePTY1()
480- defer releasePTY2()
481- d := newTestDaemon(t, nil, stubClock{})
482- tr1, sends1 := newCapturingTransport(t)
483- tr2, _ := newCapturingTransport(t)
484- ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
485- ac1.initOverlays()
486- ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
487- ac2.initOverlays()
488- sctx1, cancel1 := context.WithCancel(d.serveCtx)
489- sctx2, cancel2 := context.WithCancel(d.serveCtx)
490- defer cancel1()
491- defer cancel2()
492- sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}
493- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
494- ac1.setSession(sess1)
495- ac2.setSession(sess2)
496- ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil)
497- ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2}, nil)
498- d.sessions[sess1.id] = sess1
499- d.sessions[sess2.id] = sess2
500-
501- d.enterPicker(sess1, ac1)
502- awaitFrame(t, sends1, ports.MsgOutput)
503- d.handleInput(sess1, ac1, []byte("j"))
504- awaitFrame(t, sends1, ports.MsgOutput)
505-
506- previewTab := sess2.tabs[0]
507- previewTab.mu.Lock()
508- previewTab.focusedPane().screen.Write([]byte("cross-session-preview-live"))
509- previewTab.mu.Unlock()
510- d.render(sess2, previewTab, previewTab.focusedPane())
511-
512- previewOut := awaitFrame(t, sends1, ports.MsgOutput)
513- previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
514- require.NoError(t, err)
515- require.Contains(t, string(previewMsg.Data), "cross-session-preview-live")
516-}
517-
518-func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
519- d, sess, ac, sends, releases := newManualTabSession(t, 3)
520- defer func() {
521- for _, release := range releases {
522- release()
523- }
524- }()
525-
526- done := make(chan struct{})
527- var drain sync.WaitGroup
528- drain.Go(func() {
529- for {
530- select {
531- case <-sends:
532- case <-done:
533- return
534- }
535- }
536- })
537-
538- var wg sync.WaitGroup
539- for i := range 100 {
540- wg.Go(func() {
541- d.enterPicker(sess, ac)
542- })
543- wg.Go(func() {
544- d.handlePickerInput(ac, []byte("j"))
545- d.handlePickerInput(ac, []byte("k"))
546- })
547- wg.Go(func() {
548- d.closePicker(ac)
549- })
550- tb := sess.tabs[i%len(sess.tabs)]
551- wg.Go(func() {
552- tb.mu.Lock()
553- p := tb.focusedPane()
554- tb.mu.Unlock()
555- if p != nil {
556- p.mu.Lock()
557- p.screen.Write([]byte("render-race"))
558- p.mu.Unlock()
559- }
560- d.render(sess, tb, p)
561- })
562- }
563- wg.Wait()
564- close(done)
565- drain.Wait()
566-}
567-
568 func TestPickerModalGeometry(t *testing.T) {486 func TestPickerModalGeometry(t *testing.T) {
569 base := domain.Size{Cols: 100, Rows: 40}487 base := domain.Size{Cols: 100, Rows: 40}
570 488
M internal/usecase/daemon/prompt.go +5 -5
@@ -20,7 +20,7 @@ func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial s
20 ac.overlays.promptSubmit = submit20 ac.overlays.promptSubmit = submit
21 ac.overlays.promptPending = nil21 ac.overlays.promptPending = nil
22 ac.overlays.promptMu.Unlock()22 ac.overlays.promptMu.Unlock()
23- d.paint(sess, ac, true)23+ d.invalidateRender(sess, ac, true, "prompt.go")
24 }24 }
25 25
26 func (d *Daemon) closePrompt(ac *attachedClient) {26 func (d *Daemon) closePrompt(ac *attachedClient) {
@@ -77,22 +77,22 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
77 ac.overlays.prompt.SetError(err.Error())77 ac.overlays.prompt.SetError(err.Error())
78 }78 }
79 ac.overlays.promptMu.Unlock()79 ac.overlays.promptMu.Unlock()
80- d.paint(sess, ac, true)80+ d.invalidateRender(sess, ac, true, "prompt.go")
81 return81 return
82 }82 }
83 d.closePrompt(ac)83 d.closePrompt(ac)
84 if current := ac.currentSession(); current != nil {84 if current := ac.currentSession(); current != nil {
85- d.paint(current, ac, true)85+ d.invalidateRender(current, ac, true, "prompt.go")
86 }86 }
87 return87 return
88 }88 }
89 if exit {89 if exit {
90 d.closePrompt(ac)90 d.closePrompt(ac)
91- d.paint(sess, ac, true)91+ d.invalidateRender(sess, ac, true, "prompt.go")
92 return92 return
93 }93 }
94 if changed {94 if changed {
95- d.paint(sess, ac, true)95+ d.invalidateRender(sess, ac, true, "prompt.go")
96 }96 }
97 }97 }
98 98
M internal/usecase/daemon/render.go +170 -206
@@ -1,34 +1,10 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the1+// Package daemon holds vev's server-side session multiplexer use case.
2-// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.
4-//
5-// Concurrency model (sessions own one or more PTY-backed tabs):
6-//
7-// - Serve runs the accept loop. Each accepted connection is handled by its
8-// own goroutine (handleConn): it reads the first frame and routes it to a
9-// session create/attach, a list, or a kill.
10-// - Per session there are exactly two long-lived goroutines: the PTY reader
11-// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12-// and the render scheduler (debounces dirties and paints the attached
13-// client). Both are tied to the session context and unwind when the
14-// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15-// scheduler).
16-// - The daemon exits (Serve returns) when the last session is removed, or
17-// when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).
19-//
20-// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25 package daemon2 package daemon
26 3
27 import (4 import (
28 "bytes"5 "bytes"
29 "errors"6 "errors"
30 "strconv"7 "strconv"
31- "time"
32 8
33 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
34 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
@@ -39,6 +15,14 @@ import (
39 "github.com/bnema/vev/pkg/vt"15 "github.com/bnema/vev/pkg/vt"
40 )16 )
41 17
18+// completedSynchronizedUpdate reports a full DEC 2026 batch contained in one
19+// PTY read. The VT's final state is inactive in this case, so it cannot be
20+// inferred from before/after SyncUpdateActive alone.
21+func completedSynchronizedUpdate(data []byte) bool {
22+ _, tail, found := bytes.Cut(data, []byte(renderer.SyncStartCSI))
23+ return found && bytes.Contains(tail, []byte(renderer.SyncEndCSI))
24+}
25+
42 func signal(ch chan struct{}) {26 func signal(ch chan struct{}) {
43 select {27 select {
44 case ch <- struct{}{}:28 case ch <- struct{}{}:
@@ -46,6 +30,81 @@ func signal(ch chan struct{}) {
46 }30 }
47 }31 }
48 32
33+// paneRenderable reports dynamic composition visibility. Callers must not
34+// hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks.
35+func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
36+ if sess == nil || tb == nil || p == nil {
37+ return false
38+ }
39+ preview := d.tabIsPickerPreview(tb)
40+ sess.mu.Lock()
41+ active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
42+ attached := sess.client != nil
43+ sess.mu.Unlock()
44+ tb.mu.Lock()
45+ defer tb.mu.Unlock()
46+ if tb.floating.pane == p {
47+ return active && attached && tb.floating.state == floatingVisible
48+ }
49+ if !((active && attached) || preview) {
50+ return false
51+ }
52+ placements, ok := solvedPlacementsLocked(tb)
53+ if !ok {
54+ return false
55+ }
56+ for _, placement := range placements {
57+ if placement.ID == p.id && !placement.Collapsed && placement.Content.Width > 0 && placement.Content.Height > 0 {
58+ return true
59+ }
60+ }
61+ return false
62+}
63+
64+// clearNonRenderablePaneDamage is the single transitional S1 owner for VT
65+// damage that cannot reach a composition target. S2 replaces this visibility
66+// decision with pipeline ownership; do not paint or mutate renderer shadows
67+// here.
68+func (d *Daemon) clearNonRenderablePaneDamage(sess *session, tb *tab, p *pane) bool {
69+ renderable := d.paneRenderable(sess, tb, p)
70+ if !renderable && p != nil {
71+ p.mu.Lock()
72+ p.screen.ClearDamage()
73+ p.mu.Unlock()
74+ }
75+ return renderable
76+}
77+
78+// tabIsPickerPreview reports whether any attached client currently composes
79+// tb as a picker preview. It snapshots daemon ownership before taking overlay
80+// locks, preserving the Daemon -> session -> tab -> pane lock order.
81+func (d *Daemon) tabIsPickerPreview(tb *tab) bool {
82+ if d == nil || tb == nil {
83+ return false
84+ }
85+ d.mu.Lock()
86+ sessions := make([]*session, 0, len(d.sessions))
87+ for _, sess := range d.sessions {
88+ sessions = append(sessions, sess)
89+ }
90+ d.mu.Unlock()
91+ for _, sess := range sessions {
92+ sess.mu.Lock()
93+ ac := sess.client
94+ sess.mu.Unlock()
95+ if ac == nil || ac.overlays == nil {
96+ continue
97+ }
98+ ac.overlays.pickerMu.Lock()
99+ preview := ac.overlays.pickerPreview == tb
100+ ac.overlays.pickerMu.Unlock()
101+ if preview {
102+ return true
103+ }
104+ }
105+ return false
106+}
107+
49 func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {108 func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
50 defer d.sessWg.Done()109 defer d.sessWg.Done()
51 if p == nil {110 if p == nil {
@@ -66,12 +125,40 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
66 n, err := p.pty.Read(buf)125 n, err := p.pty.Read(buf)
67 if n > 0 {126 if n > 0 {
68 data := buf[:n]127 data := buf[:n]
128+ rc := sess.renderCoordinator()
69 p.mu.Lock()129 p.mu.Lock()
70 wasSyncing := p.screen.SyncUpdateActive()130 wasSyncing := p.screen.SyncUpdateActive()
71 p.screen.Write(data)131 p.screen.Write(data)
72 p.refreshTerminalTitleLocked()132 p.refreshTerminalTitleLocked()
73 isSyncing := p.screen.SyncUpdateActive()133 isSyncing := p.screen.SyncUpdateActive()
134+ completeSyncRead := !wasSyncing && !isSyncing && completedSynchronizedUpdate(data)
135+ var syncGen uint64
136+ syncEnded := false
137+ if wasSyncing != isSyncing {
138+ if isSyncing {
139+ syncGen = sess.syncGen.Add(1)
140+ p.syncGen = syncGen
141+ // Publish the gate while the authoritative screen mutation is
142+ // still protected. A concurrent deadline therefore cannot
143+ // compose this partial DEC 2026 payload.
144+ if rc != nil {
145+ rc.noteSyncBeginWithRenderability(p, syncGen, func() bool {
146+ return d.paneRenderable(sess, tb, p)
147+ }, func() {
148+ p.mu.Lock()
149+ if p.syncGen == syncGen && p.screen.SyncUpdateActive() {
150+ p.screen.ForceSyncEnd()
151+ }
152+ p.mu.Unlock()
153+ })
154+ }
155+ } else {
156+ syncGen = p.syncGen
157+ syncEnded = rc != nil && rc.removeSyncEnd(p, syncGen, true)
158+ }
159+ }
74 p.mu.Unlock()160 p.mu.Unlock()
161+ renderable := d.clearNonRenderablePaneDamage(sess, tb, p)
75 markSnapshotDirty(sess)162 markSnapshotDirty(sess)
76 select {163 select {
77 case <-attentionCh:164 case <-attentionCh:
@@ -90,23 +177,31 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
90 }177 }
91 resp = resp[:0]178 resp = resp[:0]
92 }179 }
93- if wasSyncing != isSyncing {180+ if rc != nil {
94- p.mu.Lock()181+ if wasSyncing != isSyncing {
95- p.syncGen++182+ if isSyncing {
96- gen := p.syncGen183+ // The accumulated synchronized batch is the pending render only
97- p.mu.Unlock()184+ // while this pane can currently reach a composition target.
98- if isSyncing {185+ if renderable {
99- go d.syncWatchdog(p, gen)186+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
187+ }
188+ } else if syncEnded && renderable {
189+ // State removal happened under pane.mu. Publish a new urgent
190+ // completion after unlock: detach/replace may have cleared the
191+ // pending batch work, so fireCurrent alone could not wake a
192+ // headless picker preview or replacement attachment.
193+ rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
194+ }
195+ }
196+ if completeSyncRead && renderable {
197+ // The entire batch completed in this read. Its final screen state
198+ // is ready now, so bypass the bulk debounce without arming a
199+ // watchdog for a batch that no longer exists.
200+ rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
201+ } else if renderable && !isSyncing && wasSyncing == isSyncing {
202+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
100 }203 }
101 }204 }
102- if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) {
103- signal(p.flush)
104- continue
105- }
106- if isSyncing {
107- continue
108- }
109- signal(p.dirty)
110 }205 }
111 if err != nil {206 if err != nil {
112 if p.onExit != nil {207 if p.onExit != nil {
@@ -119,159 +214,6 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
119 }214 }
120 }215 }
121 216
122-// scheduler debounces dirty signals. The first dirty opens a short tab;
123-// sustained floods progressively widen that tab, while isolated updates
124-// return to the minimum delay for interactive latency.
125-func (d *Daemon) scheduler(sess *session, tb *tab, p *pane) {
126- defer d.sessWg.Done()
127- if p == nil {
128- return
129- }
130- delay := minDebounceInterval
131- lastRender := d.clock.Now()
132-outer:
133- for {
134- select {
135- case <-sess.ctx.Done():
136- return
137- case <-paneDone(p):
138- return
139- case <-p.flush:
140- d.render(sess, tb, p)
141- lastRender = d.clock.Now()
142- continue
143- case <-p.dirty:
144- if d.clock.Now().Sub(lastRender) >= maxDebounceInterval {
145- delay = minDebounceInterval
146- }
147- }
148-
149- coalesced := 0
150- timer := d.clock.NewTimer(delay)
151- absorb:
152- for {
153- select {
154- case <-sess.ctx.Done():
155- timer.Stop()
156- return
157- case <-paneDone(p):
158- timer.Stop()
159- return
160- case <-p.flush:
161- if !timer.Stop() {
162- select {
163- case <-timer.C():
164- default:
165- }
166- }
167- d.render(sess, tb, p)
168- lastRender = d.clock.Now()
169- continue outer
170- case <-p.dirty:
171- coalesced++
172- case <-timer.C():
173- break absorb
174- }
175- }
176- delay = nextDebounceDelay(delay, coalesced)
177- d.render(sess, tb, p)
178- lastRender = d.clock.Now()
179- }
180-}
181-
182-func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration {
183- if coalesced == 0 {
184- return minDebounceInterval
185- }
186- if delay >= maxDebounceInterval {
187- return maxDebounceInterval
188- }
189- delay += debounceStep
190- if delay > maxDebounceInterval {
191- return maxDebounceInterval
192- }
193- return delay
194-}
195-
196-func (d *Daemon) syncWatchdog(p *pane, gen uint64) {
197- timer := d.clock.NewTimer(maxSyncUpdateDuration)
198- select {
199- case <-paneDone(p):
200- timer.Stop()
201- return
202- case <-timer.C():
203- }
204-
205- p.mu.Lock()
206- if p.syncGen != gen || !p.screen.SyncUpdateActive() {
207- p.mu.Unlock()
208- return
209- }
210- p.screen.ForceSyncEnd()
211- p.mu.Unlock()
212- signal(p.flush)
213-}
214-
215-func syncUpdateEndIn(data []byte) bool {
216- return bytes.Contains(data, []byte("\x1b[?2026l"))
217-}
218-
219-// render paints the current client, or (when detached) just clears accumulated
220-// damage so it never grows unbounded while headless.
221-func (d *Daemon) render(sess *session, tb *tab, p *pane) {
222- if p == nil {
223- return
224- }
225- p.mu.Lock()
226- if p.screen.SyncUpdateActive() {
227- p.mu.Unlock()
228- return
229- }
230- p.mu.Unlock()
231-
232- // A retained floating pane continues to drain its PTY while hidden. Its
233- // scheduler must not route through picker preview (or the active client),
234- // but it must consume damage so a long-hidden command cannot retain it.
235- tb.mu.Lock()
236- hiddenFloating := tb.floating.pane == p && tb.floating.state != floatingVisible
237- previewer := tb.previewClient
238- tb.mu.Unlock()
239- if hiddenFloating {
240- p.mu.Lock()
241- p.screen.ClearDamage()
242- p.mu.Unlock()
243- return
244- }
245-
246- sess.mu.Lock()
247- ac := sess.client
248- active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
249- sess.mu.Unlock()
250-
251- if previewer != nil {
252- if previewSess := previewer.currentSession(); previewSess != nil {
253- d.paint(previewSess, previewer, false)
254- }
255- if ac != nil && active && ac != previewer {
256- d.paint(sess, ac, false)
257- return
258- }
259- p.mu.Lock()
260- p.screen.ClearDamage()
261- p.mu.Unlock()
262- return
263- }
264-
265- if ac == nil || !active {
266- p.mu.Lock()
267- p.screen.ClearDamage()
268- p.mu.Unlock()
269- return
270- }
271- d.refreshBarScriptsIfDue(sess, d.clock.Now(), false)
272- d.paint(sess, ac, false)
273-}
274-
275 // paint draws the composed client frame (active tab plus status bar) and217 // paint draws the composed client frame (active tab plus status bar) and
276 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations218 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
277 // such as switch/create/close/rename/resize so the repaint is complete.219 // such as switch/create/close/rename/resize so the repaint is complete.
@@ -318,23 +260,49 @@ func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) {
318 generation := ac.resizePaintGeneration260 generation := ac.resizePaintGeneration
319 ac.resizePaintPending = true261 ac.resizePaintPending = true
320 ac.resizePaint.retain(d.clock, maxDebounceInterval, func(ports.Timer) {262 ac.resizePaint.retain(d.clock, maxDebounceInterval, func(ports.Timer) {
321- d.paintForResizeGeneration(sess, ac, true, generation)263+ d.invalidateForResizeGeneration(sess, ac, generation)
322 })264 })
323 }265 }
324 266
267+// invalidateForResizeGeneration preserves PR #71's attachment-owned timer,
268+// generation rejection, and cancellation while transferring only its eventual
269+// render request to the coordinator.
270+func (d *Daemon) invalidateForResizeGeneration(sess *session, ac *attachedClient, generation uint64) {
271+ ac.sendMu.Lock()
272+ if ac.currentSession() != sess || !ac.resizePaintPending || ac.resizePaintGeneration != generation {
273+ ac.sendMu.Unlock()
274+ return
275+ }
276+ ac.resizePaint.stop()
277+ ac.resizePaintPending = false
278+ ac.sendMu.Unlock()
279+ d.invalidateRender(sess, ac, true, "render.go")
280+}
281+
325 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {282 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
326- d.paintForResizeGeneration(sess, ac, reset, 0)283+ d.paintForResizeGeneration(sess, ac, reset, 0, 0)
327 }284 }
328 285
329-func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, reset bool, resizeGeneration uint64) {286+// paintCoordinatorWake composes a coordinator wake only for its captured
287+// attachment incarnation. The epoch is checked after sendMu is acquired.
288+func (d *Daemon) paintCoordinatorWake(sess *session, w renderWake) {
289+ d.paintForResizeGeneration(sess, w.attachment, w.reset, 0, w.attachmentEpoch)
290+}
291+
292+func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, reset bool, resizeGeneration, attachmentEpoch uint64) {
330 tb := sess.activeTab()293 tb := sess.activeTab()
331 if tb == nil {294 if tb == nil {
332 return295 return
333 }296 }
334 297
335- ac.initOverlays()
336 ac.sendMu.Lock()298 ac.sendMu.Lock()
337- if ac.currentSession() != sess {299+ // sendMu is the attachment ownership boundary. Check the session's
300+ // published identity while holding it so a deadline captured before an
301+ // attach/replace cannot emit on either the old or new output chain.
302+ sess.mu.Lock()
303+ owned := sess.client == ac
304+ sess.mu.Unlock()
305+ if !owned || ac.currentSession() != sess || (attachmentEpoch != 0 && (ac.coordinatorEpoch.Load() != attachmentEpoch || ac.coordinatorReadyEpoch.Load() != attachmentEpoch)) {
338 ac.sendMu.Unlock()306 ac.sendMu.Unlock()
339 return307 return
340 }308 }
@@ -342,19 +310,15 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
342 ac.sendMu.Unlock()310 ac.sendMu.Unlock()
343 return311 return
344 }312 }
313+ // Composition owns attachment sendMu; initialize its lazy overlay state
314+ // under that same ownership so concurrent fallback paints cannot observe a
315+ // partially published runtime.
316+ ac.initOverlays()
345 if ac.resizePaintPending {317 if ac.resizePaintPending {
346 ac.resizePaint.stop()318 ac.resizePaint.stop()
347 ac.resizePaintPending = false319 ac.resizePaintPending = false
348 reset = true320 reset = true
349 }321 }
350- // Ack-gated coalescing: while the client is at least maxUnackedOutputStates
351- // behind, skip composing another diff and mark the paint deferred. The
352- // MsgOutputAck handler flushes one cumulative paint once acks catch up. A
353- // deferred reset stays sticky so the cumulative paint is dependency-free.
354- if ac.output.deferIfAtCapacity(reset) {
355- ac.sendMu.Unlock()
356- return
357- }
358 overlays := ac.overlays.SnapshotForRender()322 overlays := ac.overlays.SnapshotForRender()
359 repaintAttachedClients := false323 repaintAttachedClients := false
360 defer func() {324 defer func() {
A internal/usecase/daemon/render_coordinator.go +899 -0
@@ -0,0 +1,899 @@
1+package daemon
2+
3+import (
4+ "runtime"
5+ "sync"
6+ "sync/atomic"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+// S1 render coordinator contract. One coordinator serves one attached
14+// session: producers mutate authoritative state and publish an invalidation;
15+// only the coordinator decides when the transitional composition target runs.
16+// Producers never compose or send. This file currently carries the inert
17+// contract — state, options, and signatures exist so the behavioral
18+// specification compiles — while wake scheduling, ACK gating, synchronized
19+// output handling, and producer migration land in the GREEN slice.
20+
21+// Coordinator deadline bounds. Urgent transitions (input echo, overlay state
22+// changes) must become visible within urgentRenderDeadline; bulk output
23+// coalesces inside the adaptive [minOutputRenderDeadline,
24+// maxOutputRenderDeadline] window.
25+const (
26+ urgentRenderDeadline = 2 * time.Millisecond
27+ minOutputRenderDeadline = 8 * time.Millisecond
28+ maxOutputRenderDeadline = 16 * time.Millisecond
29+)
30+
31+// invalidationClass ranks a producer transition's latency demand.
32+type invalidationClass uint8
33+
34+const (
35+ // invalidateOutput is bulk PTY/screen churn served by the adaptive window.
36+ invalidateOutput invalidationClass = iota
37+ // invalidateUrgent is an interactive transition served within 2ms.
38+ invalidateUrgent
39+)
40+
41+// renderInvalidation is one producer-published state transition.
42+type renderInvalidation struct {
43+ class invalidationClass
44+ // reset requests a sticky FullRedraw: once requested it survives
45+ // coalescing and ACK deferral until a wake actually delivers it.
46+ reset bool
47+ // producer identifies the publishing call-site for provenance.
48+ producer string
49+}
50+
51+// renderWake is one coalesced composition request delivered to the
52+// transitional composition target (replaced by the S2 pipeline).
53+type renderWake struct {
54+ reset bool
55+ urgent bool
56+ coalesced int
57+ // attachment is snapshotted under coordinator ownership. The wake
58+ // consumer must compose only for this exact client, never by rereading
59+ // session.client after an attach publication.
60+ attachment *attachedClient
61+ // watchdog marks a flush forced by the synchronized-output watchdog.
62+ watchdog bool
63+ // attachmentEpoch is captured with attachment and must still be current
64+ // before this wake may compose or send.
65+ attachmentEpoch uint64
66+}
67+
68+// resizeRequestMetadata is the coordinator-owned latest requested resize
69+// state: S3's transaction entry point reads it through resizeSnapshot. It is
70+// metadata ownership only — S1/S2 dispatch stays with the retained PR #71
71+// attachment timer path.
72+type resizeRequestMetadata struct {
73+ size domain.Size
74+ source *attachedClient
75+ epoch uint64
76+}
77+
78+// renderCoordinatorOptions wires one coordinator instance.
79+type renderCoordinatorOptions struct {
80+ clock ports.Clock
81+ // wake is the transitional composition target.
82+ wake func(renderWake)
83+ // ackReady reports whether the attachment may compose another output
84+ // state (the outputStateStream window has capacity).
85+ ackReady func() bool
86+ // syncRenderable reports whether a pane's synchronized batch currently
87+ // contributes to the attached composition. It must not acquire c.mu.
88+ syncRenderable func(*pane) bool
89+ // syncActive is retained for existing test harness construction; per-pane
90+ // lifecycle and syncRenderable are the coordinator gate.
91+ syncActive func() bool
92+ // onInvalidate observes every published invalidation (test-visible hook).
93+ onInvalidate func(renderInvalidation)
94+ // afterSyncGateEvaluated is a package-private deterministic test seam. It
95+ // runs unlocked after visibility predicates and before registry validation.
96+ afterSyncGateEvaluated func()
97+}
98+
99+// renderCoordinator fans in producer invalidations for one attached session.
100+// renderCoordinatorBurstMetrics is deliberately observational. It permits
101+// benchmark/pprof callers to measure producer bursts without influencing
102+// scheduling or transport policy.
103+type renderCoordinatorBurstMetrics struct {
104+ invalidations atomic.Uint64
105+ wakes atomic.Uint64
106+ coalesced atomic.Uint64
107+}
108+
109+// renderCoordinatorBurstMetricsSnapshot is an immutable, internal view for
110+// benchmark reporting. Scheduling only writes the atomic counters above.
111+type renderCoordinatorBurstMetricsSnapshot struct {
112+ invalidations uint64
113+ wakes uint64
114+ coalesced uint64
115+}
116+
117+type renderCoordinator struct {
118+ mu sync.Mutex
119+ opts renderCoordinatorOptions
120+
121+ // Coalesced pending state: latest wins, reset stays sticky, and at most
122+ // one deadline wake may be armed at a time (cap-1).
123+ pending bool
124+ pendingReset bool
125+ pendingUrgent bool
126+ // pendingPreview is accounted separately from the target primary wake:
127+ // viewers may consume a coalesced target snapshot while that target is
128+ // still ACK-blocked.
129+ pendingPreview bool
130+ // ackDeferred records that an expired deadline or watchdog was blocked by
131+ // output-window capacity. ACK notifications may flush only this state.
132+ ackDeferred bool
133+ // deadlineDue closes the handoff between a timer worker receiving its tick
134+ // and fire observing unavailable ACK capacity.
135+ deadlineDue bool
136+ coalesced int
137+ // outputPressure carries recent bulk-burst pressure between completed
138+ // batches. It selects a bounded 8–16ms deadline without letting a busy
139+ // batch perpetually reset its own timer.
140+ outputPressure int
141+
142+ // previewWake receives the same coalesced wakes for the legacy single
143+ // subscriber. previewWakes tracks picker subscriptions by viewer, so one
144+ // inactive session cannot replace another viewer's live preview.
145+ previewWake func(renderWake)
146+ previewWakes map[*attachedClient]func(renderWake)
147+
148+ // attachment is the currently bound client identity; callbacks from any
149+ // other identity are stale and must not mutate coordinator state.
150+ attachment *attachedClient
151+ // attachmentEpoch distinguishes consecutive bindings of the same client
152+ // object (notably park/resume, which replaces its transport in place).
153+ attachmentEpoch uint64
154+ // attachmentReady is true only after Welcome succeeded for this exact
155+ // attachment incarnation. Internal cross-session handoffs bind ready.
156+ attachmentReady bool
157+ // detached rejects invalidations after detach/park until a new attachment.
158+ detached bool
159+ torndown bool
160+
161+ resize resizeRequestMetadata
162+ metrics renderCoordinatorBurstMetrics
163+ // generation invalidates callbacks from superseded deadline/watchdog timers.
164+ // Each worker also owns an explicit cancellation channel: fake timers may
165+ // expose a nil or never-firing C channel, so Stop alone cannot release a
166+ // worker waiting in select.
167+ generation uint64
168+ armed bool
169+ normalTimer ports.Timer
170+ normalCancel chan struct{}
171+ normalWorkerDone chan struct{}
172+ // syncBatches is keyed by the stable pane owner. Each pane owns an
173+ // independent watchdog; composition remains gated until every batch ends.
174+ syncBatches map[*pane]*syncBatch
175+ // syncRegistryVersion changes for every batch identity mutation. fire
176+ // snapshots it before evaluating visibility and retries if a producer
177+ // publishes or completes a batch during that unlocked evaluation.
178+ syncRegistryVersion uint64
179+}
180+
181+// newRenderCoordinator constructs a coordinator bound to opts. It starts no
182+// goroutine; deadline scheduling is armed lazily by invalidate.
183+func newRenderCoordinator(opts renderCoordinatorOptions) *renderCoordinator {
184+ return &renderCoordinator{opts: opts}
185+}
186+
187+// detachNormalTimerLocked releases coordinator ownership. The caller must stop
188+// the returned timer after dropping c.mu: Timer methods are external callbacks.
189+func (c *renderCoordinator) detachNormalTimerLocked() ports.Timer {
190+ timer := c.normalTimer
191+ c.normalTimer = nil
192+ if c.normalCancel != nil {
193+ close(c.normalCancel)
194+ c.normalCancel = nil
195+ }
196+ return timer
197+}
198+
199+func stopTimer(timer ports.Timer) {
200+ if timer != nil {
201+ timer.Stop()
202+ }
203+}
204+
205+type syncBatch struct {
206+ generation uint64
207+ // renderable is evaluated outside c.mu: tab/session visibility locks may be
208+ // acquired by this predicate.
209+ renderable func() bool
210+ force func()
211+ timer ports.Timer
212+ cancel chan struct{}
213+ done chan struct{}
214+}
215+
216+func detachSyncBatchLocked(batch *syncBatch) ports.Timer {
217+ timer := batch.timer
218+ batch.timer = nil
219+ if batch.cancel != nil {
220+ close(batch.cancel)
221+ batch.cancel = nil
222+ }
223+ return timer
224+}
225+
226+func (c *renderCoordinator) detachSyncTimersLocked() []ports.Timer {
227+ timers := make([]ports.Timer, 0, len(c.syncBatches))
228+ for pane, batch := range c.syncBatches {
229+ timers = append(timers, detachSyncBatchLocked(batch))
230+ delete(c.syncBatches, pane)
231+ c.syncRegistryVersion++
232+ }
233+ return timers
234+}
235+
236+func stopTimers(timers []ports.Timer) {
237+ for _, timer := range timers {
238+ stopTimer(timer)
239+ }
240+}
241+
242+// invalidate publishes a coordinator-owned producer transition. Callers that
243+// carry an attachment identity must use invalidateForAttachment instead.
244+func (c *renderCoordinator) invalidate(inv renderInvalidation) {
245+ c.invalidateForAttachment(nil, inv)
246+}
247+
248+// invalidateForAttachment publishes only while source is still the bound
249+// attachment. This check shares c.mu with replacement, making a stale
250+// attachment's timer callback unable to enqueue work for its replacement.
251+// A nil source represents an internal pane/session invalidation.
252+func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) {
253+ c.mu.Lock()
254+ // An unbound coordinator is the manual/headless harness state; there is no
255+ // published production attachment to protect there. A parked target may
256+ // still publish its own PTY/session mutations to picker observers, but no
257+ // attachment-owned callback may revive that target's render path.
258+ detachedPreviewOnly := c.detached && source == nil && (c.previewWake != nil || len(c.previewWakes) != 0)
259+ if c.torndown || (c.detached && !detachedPreviewOnly) || (source != nil && c.attachment != nil && c.attachment != source) {
260+ c.mu.Unlock()
261+ return
262+ }
263+ onInvalidate := c.opts.onInvalidate
264+ c.metrics.invalidations.Add(1)
265+ wasPending, wasUrgent, wasPreviewPending := c.pending, c.pendingUrgent, c.pendingPreview
266+ if !wasPending {
267+ c.ackDeferred = false
268+ c.deadlineDue = false
269+ }
270+ c.pending = true
271+ c.pendingReset = c.pendingReset || inv.reset
272+ c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent
273+ c.pendingPreview = c.pendingPreview || c.previewWake != nil || len(c.previewWakes) != 0
274+ c.coalesced++
275+ // A deadline may have expired while synchronized output still gated the
276+ // pending work. Completion republishes urgently and must reserve a fresh
277+ // deadline rather than leaving that already-fired timer as the only arm.
278+ arm := !wasPending || (!wasUrgent && c.pendingUrgent) || (!wasPreviewPending && c.pendingPreview) || c.deadlineDue
279+ var old ports.Timer
280+ if arm {
281+ c.generation++
282+ old = c.detachNormalTimerLocked()
283+ }
284+ gen := c.generation
285+ delay := minOutputRenderDeadline + time.Duration(c.outputPressure)*time.Millisecond
286+ if c.pendingUrgent {
287+ delay = urgentRenderDeadline
288+ }
289+ clock := c.opts.clock
290+ c.armed = c.armed || arm
291+ c.mu.Unlock()
292+ stopTimer(old)
293+ if !arm || clock == nil {
294+ if onInvalidate != nil {
295+ onInvalidate(inv)
296+ }
297+ return
298+ }
299+
300+ // NewTimer and C may re-enter the coordinator. Publish only if this
301+ // generation is still the reserved deadline after those external calls.
302+ timer := clock.NewTimer(delay)
303+ timerC := timer.C()
304+ cancel := make(chan struct{})
305+ done := make(chan struct{})
306+ c.mu.Lock()
307+ valid := !c.torndown && c.pending && c.generation == gen && c.normalTimer == nil
308+ if valid && timerC != nil {
309+ c.normalTimer, c.normalCancel, c.normalWorkerDone = timer, cancel, done
310+ }
311+ c.mu.Unlock()
312+ if !valid {
313+ stopTimer(timer)
314+ if onInvalidate != nil {
315+ onInvalidate(inv)
316+ }
317+ return
318+ }
319+ if timerC == nil {
320+ stopTimer(timer)
321+ c.fire(gen, false, true)
322+ if onInvalidate != nil {
323+ onInvalidate(inv)
324+ }
325+ return
326+ }
327+ go func() {
328+ defer close(done)
329+ select {
330+ case <-timerC:
331+ c.markDeadlineDue(gen)
332+ c.fire(gen, false, true)
333+ case <-cancel:
334+ }
335+ }()
336+ // Test-visible observation follows deadline publication, so a producer
337+ // callback cannot observe a successful invalidation before its timer owns
338+ // a worker (and race tests cannot finish while a mock expectation remains).
339+ if onInvalidate != nil {
340+ onInvalidate(inv)
341+ }
342+ runtime.Gosched()
343+}
344+
345+// notifyAck reports that the client acknowledged an output state, releasing
346+// at most one deadline/watchdog-deferred wake. It never bypasses an unexpired
347+// normal or urgent deadline.
348+func (c *renderCoordinator) notifyAck() {
349+ c.mu.Lock()
350+ if (!c.ackDeferred && !c.deadlineDue) || !c.pending {
351+ c.mu.Unlock()
352+ return
353+ }
354+ gen := c.generation
355+ c.mu.Unlock()
356+ c.fire(gen, false, false)
357+}
358+
359+// noteSyncBegin records a synchronized-output batch for its stable pane and
360+// arms that pane's watchdog. Overlapping pane batches are independent.
361+// markDeadlineDue records a received timer tick before the worker probes ACK
362+// readiness, so a concurrent ACK cannot miss an already-expired deadline.
363+func (c *renderCoordinator) markDeadlineDue(gen uint64) {
364+ c.mu.Lock()
365+ if c.fireValidLocked(gen, false) {
366+ c.deadlineDue = true
367+ }
368+ c.mu.Unlock()
369+}
370+
371+func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func()) {
372+ var renderable func() bool
373+ if c.opts.syncRenderable != nil {
374+ renderable = func() bool { return c.opts.syncRenderable(p) }
375+ }
376+ c.noteSyncBeginWithRenderability(p, gen, renderable, force...)
377+}
378+
379+// noteSyncBeginWithRenderability records lifecycle unconditionally while the
380+// supplied predicate decides dynamically whether this batch gates composition.
381+func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64, renderable func() bool, force ...func()) {
382+ c.mu.Lock()
383+ if c.torndown {
384+ c.mu.Unlock()
385+ return
386+ }
387+ if c.syncBatches == nil {
388+ c.syncBatches = make(map[*pane]*syncBatch)
389+ }
390+ var old ports.Timer
391+ if previous := c.syncBatches[p]; previous != nil {
392+ old = detachSyncBatchLocked(previous)
393+ }
394+ batch := &syncBatch{generation: gen, renderable: renderable}
395+ if len(force) != 0 {
396+ batch.force = force[0]
397+ }
398+ c.syncBatches[p] = batch
399+ c.syncRegistryVersion++
400+ clock := c.opts.clock
401+ c.mu.Unlock()
402+ stopTimer(old)
403+ if clock == nil {
404+ return
405+ }
406+
407+ // Both timer operations are external. A reentrant callback may replace or
408+ // remove batch; identity validation below rejects this stale timer.
409+ timer := clock.NewTimer(maxSyncUpdateDuration)
410+ timerC := timer.C()
411+ cancel := make(chan struct{})
412+ done := make(chan struct{})
413+ c.mu.Lock()
414+ valid := !c.torndown && c.syncBatches[p] == batch && batch.generation == gen
415+ if valid {
416+ batch.timer, batch.cancel, batch.done = timer, cancel, done
417+ }
418+ c.mu.Unlock()
419+ if !valid {
420+ stopTimer(timer)
421+ return
422+ }
423+ go func() {
424+ defer close(done)
425+ select {
426+ case <-timerC:
427+ case <-cancel:
428+ return
429+ }
430+ // Keep the batch registered while force runs. A concurrent render must
431+ // continue to observe the synchronized-output gate until the VT has
432+ // authoritatively closed the batch.
433+ c.mu.Lock()
434+ current := c.syncBatches[p]
435+ valid := !c.torndown && current == batch && current.generation == gen
436+ c.mu.Unlock()
437+ if !valid {
438+ return
439+ }
440+ if batch.force != nil {
441+ batch.force()
442+ }
443+
444+ // force may synchronously end this batch, replace it with a newer
445+ // generation, or tear down the coordinator. Remove only the exact
446+ // snapshot that expired, then publish one urgent completion wake.
447+ c.mu.Lock()
448+ current = c.syncBatches[p]
449+ valid = !c.torndown && current == batch && current.generation == gen
450+ var stopped ports.Timer
451+ if valid {
452+ delete(c.syncBatches, p)
453+ c.syncRegistryVersion++
454+ stopped = detachSyncBatchLocked(batch)
455+ if len(c.syncBatches) == 0 && c.pending {
456+ c.pendingUrgent = true
457+ }
458+ }
459+ c.mu.Unlock()
460+ if valid {
461+ stopTimer(stopped)
462+ // Reevaluate the aggregate gate only after forcing this pane.
463+ c.fireCurrent(true)
464+ }
465+ }()
466+}
467+
468+// noteSyncEnd records completion for exactly one pane batch. It only flushes
469+// after the aggregate gate opens.
470+func (c *renderCoordinator) noteSyncEnd(p *pane, gen uint64) {
471+ // Visibility may acquire session/tab locks, so evaluate it outside c.mu.
472+ c.mu.Lock()
473+ batch := c.syncBatches[p]
474+ if batch == nil || batch.generation != gen {
475+ c.mu.Unlock()
476+ return
477+ }
478+ renderable := batch.renderable
479+ c.mu.Unlock()
480+ wasRenderable := renderable == nil || renderable()
481+ if c.removeSyncEnd(p, gen, wasRenderable) {
482+ c.fireCurrent(false)
483+ }
484+}
485+
486+// removeSyncEnd removes a batch without invoking a predicate or callback. PTY
487+// processing calls it while pane.mu is still held, so a deadline can never
488+// compose the post-Write partial screen between mutation and publication.
489+// The caller must invoke fireCurrent only after releasing pane.mu.
490+func (c *renderCoordinator) removeSyncEnd(p *pane, gen uint64, renderable bool) bool {
491+ c.mu.Lock()
492+ batch := c.syncBatches[p]
493+ if batch == nil || batch.generation != gen {
494+ c.mu.Unlock()
495+ return false
496+ }
497+ delete(c.syncBatches, p)
498+ c.syncRegistryVersion++
499+ timer := detachSyncBatchLocked(batch)
500+ if renderable && len(c.syncBatches) == 0 && c.pending {
501+ c.pendingUrgent = true
502+ }
503+ c.mu.Unlock()
504+ stopTimer(timer)
505+ return renderable
506+}
507+
508+// noteSyncPaneRemoved releases a pane watchdog when pane lifecycle ends.
509+func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) {
510+ c.mu.Lock()
511+ var timer ports.Timer
512+ if batch := c.syncBatches[p]; batch != nil {
513+ delete(c.syncBatches, p)
514+ c.syncRegistryVersion++
515+ timer = detachSyncBatchLocked(batch)
516+ }
517+ c.mu.Unlock()
518+ stopTimer(timer)
519+}
520+
521+// subscribePreview installs fn as the preview observer for coalesced wakes.
522+func (c *renderCoordinator) subscribePreview(fn func(renderWake)) {
523+ c.mu.Lock()
524+ c.previewWake = fn
525+ c.mu.Unlock()
526+}
527+
528+// teardownPreview removes the legacy preview subscription.
529+func (c *renderCoordinator) teardownPreview() { c.mu.Lock(); c.previewWake = nil; c.mu.Unlock() }
530+
531+// subscribePreviewFor installs the dynamic picker observer owned by viewer.
532+// The owner key makes target changes, detach, and session teardown precise.
533+func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, fn func(renderWake)) {
534+ if viewer == nil || fn == nil {
535+ return
536+ }
537+ c.mu.Lock()
538+ if !c.torndown {
539+ if c.previewWakes == nil {
540+ c.previewWakes = make(map[*attachedClient]func(renderWake))
541+ }
542+ c.previewWakes[viewer] = fn
543+ }
544+ c.mu.Unlock()
545+}
546+
547+// teardownPreviewFor removes only viewer's dynamic picker observer.
548+func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient) {
549+ c.mu.Lock()
550+ delete(c.previewWakes, viewer)
551+ c.mu.Unlock()
552+}
553+
554+// advanceAttachmentEpochLocked invalidates every previously dispatched wake.
555+// It mirrors the epoch onto the client so paint can recheck ownership after it
556+// acquires sendMu without taking c.mu and creating a lock cycle.
557+func (c *renderCoordinator) advanceAttachmentEpochLocked(clients ...*attachedClient) {
558+ c.attachmentEpoch++
559+ for _, ac := range clients {
560+ if ac != nil {
561+ ac.coordinatorEpoch.Store(c.attachmentEpoch)
562+ ac.coordinatorReadyEpoch.Store(0)
563+ }
564+ }
565+}
566+
567+// attach binds an already-handshaken internal attachment. Route/resume use
568+// attachWithReadiness(..., false) until their Welcome frame is accepted.
569+func (c *renderCoordinator) attach(ac *attachedClient) { c.attachWithReadiness(ac, true) }
570+
571+func (c *renderCoordinator) attachWithReadiness(ac *attachedClient, ready bool) {
572+ c.mu.Lock()
573+ if !c.torndown && c.attachment != ac {
574+ c.advanceAttachmentEpochLocked(ac)
575+ c.attachment = ac
576+ c.detached = false
577+ c.attachmentReady = ready
578+ if ready && ac != nil {
579+ ac.coordinatorReadyEpoch.Store(c.attachmentEpoch)
580+ }
581+ }
582+ c.mu.Unlock()
583+}
584+
585+// markAttachmentReady completes the transport handshake for exactly the
586+// current attachment incarnation. A stale Welcome can never revive a parked,
587+// replaced, or detached attachment.
588+func (c *renderCoordinator) markAttachmentReady(ac *attachedClient) bool {
589+ c.mu.Lock()
590+ defer c.mu.Unlock()
591+ if c.torndown || c.detached || c.attachment != ac || ac == nil || ac.coordinatorEpoch.Load() != c.attachmentEpoch {
592+ return false
593+ }
594+ c.attachmentReady = true
595+ ac.coordinatorReadyEpoch.Store(c.attachmentEpoch)
596+ return true
597+}
598+
599+// wakeCurrent validates a dispatched wake without entering sendMu. The paint
600+// path repeats the epoch check after taking sendMu, closing the remaining
601+// lifecycle handoff window without holding c.mu across composition.
602+func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
603+ c.mu.Lock()
604+ defer c.mu.Unlock()
605+ return !c.torndown && !c.detached && c.attachmentReady && c.attachment == w.attachment && c.attachmentEpoch == w.attachmentEpoch
606+}
607+
608+// noteDetach invalidates pending wakes and stale callbacks for a detaching
609+// attachment.
610+func (c *renderCoordinator) noteDetach(ac *attachedClient) {
611+ c.mu.Lock()
612+ var timer ports.Timer
613+ if c.attachment == ac {
614+ c.advanceAttachmentEpochLocked(ac)
615+ c.attachment = nil
616+ c.attachmentReady = false
617+ c.detached = true
618+ c.pending = false
619+ c.pendingReset = false
620+ c.pendingUrgent = false
621+ c.ackDeferred = false
622+ c.pendingPreview = false
623+ c.coalesced = 0
624+ c.generation++
625+ c.armed = false
626+ timer = c.detachNormalTimerLocked()
627+ }
628+ c.mu.Unlock()
629+ stopTimer(timer)
630+}
631+
632+// noteReplace hands the coordinator from old to replacement; callbacks
633+// captured by old become stale.
634+func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readiness ...bool) {
635+ ready := true
636+ if len(readiness) != 0 {
637+ ready = readiness[0]
638+ }
639+ c.mu.Lock()
640+ // A coordinator may be installed while replacing a legacy attachment
641+ // which predated coordinator ownership. In that case nil has no pending
642+ // identity to invalidate, and the replacement becomes the first bound
643+ // attachment atomically with this lifecycle transition.
644+ var timer ports.Timer
645+ if c.attachment == old || c.attachment == nil {
646+ c.advanceAttachmentEpochLocked(old, replacement)
647+ c.attachment = replacement
648+ c.attachmentReady = ready
649+ if ready && replacement != nil {
650+ replacement.coordinatorReadyEpoch.Store(c.attachmentEpoch)
651+ }
652+ c.detached = false
653+ c.pending = false
654+ c.pendingReset = false
655+ c.pendingUrgent = false
656+ c.ackDeferred = false
657+ c.pendingPreview = false
658+ c.coalesced = 0
659+ c.generation++
660+ c.armed = false
661+ timer = c.detachNormalTimerLocked()
662+ }
663+ c.mu.Unlock()
664+ stopTimer(timer)
665+}
666+
667+// notePark invalidates pending wakes when the attachment parks for resume.
668+func (c *renderCoordinator) notePark(ac *attachedClient) { c.noteDetach(ac) }
669+
670+// noteSessionTeardown terminally invalidates the coordinator.
671+func (c *renderCoordinator) noteSessionTeardown() {
672+ c.mu.Lock()
673+ c.torndown = true
674+ c.advanceAttachmentEpochLocked(c.attachment)
675+ c.attachment = nil
676+ c.attachmentReady = false
677+ c.previewWake = nil
678+ c.previewWakes = nil
679+ c.pending = false
680+ c.ackDeferred = false
681+ c.pendingPreview = false
682+ c.generation++
683+ c.armed = false
684+ timer := c.detachNormalTimerLocked()
685+ timers := c.detachSyncTimersLocked()
686+ c.mu.Unlock()
687+ stopTimer(timer)
688+ stopTimers(timers)
689+}
690+
691+// recordResizeRequest records the latest requested geometry and source before
692+// the request delegates to the retained PR #71 attachment path. It returns
693+// the strictly monotonically increased epoch, or 0 when the source is stale.
694+func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {
695+ c.mu.Lock()
696+ defer c.mu.Unlock()
697+ if c.torndown || c.attachment != source {
698+ return 0
699+ }
700+ c.resize.epoch++
701+ c.resize.size = size
702+ c.resize.source = source
703+ return c.resize.epoch
704+}
705+
706+// resizeSnapshot returns a locked copy of the latest resize metadata.
707+func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {
708+ c.mu.Lock()
709+ defer c.mu.Unlock()
710+ return c.resize
711+}
712+
713+// burstMetricsSnapshot returns a consistent read-only metric view for
714+// benchmark reporting without exposing coordinator policy outside this package.
715+func (c *renderCoordinator) burstMetricsSnapshot() renderCoordinatorBurstMetricsSnapshot {
716+ return renderCoordinatorBurstMetricsSnapshot{
717+ invalidations: c.metrics.invalidations.Load(),
718+ wakes: c.metrics.wakes.Load(),
719+ coalesced: c.metrics.coalesced.Load(),
720+ }
721+}
722+
723+// renderCoordinator returns the coordinator installed for this session.
724+func (s *session) renderCoordinator() *renderCoordinator { return s.coordinator.Load() }
725+
726+// installRenderCoordinator publishes rc as the session's coordinator.
727+func (s *session) installRenderCoordinator(rc *renderCoordinator) { s.coordinator.Store(rc) }
728+
729+func (c *renderCoordinator) fireCurrent(watchdog bool) {
730+ c.mu.Lock()
731+ gen := c.generation
732+ c.mu.Unlock()
733+ c.fire(gen, watchdog, watchdog)
734+}
735+
736+// invalidateRender is the sole producer fan-in. In tests and transitional
737+// headless paths without an attached coordinator it retains the old private
738+// compositor; attached sessions always schedule through their coordinator.
739+func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool, producer string) {
740+ if sess != nil {
741+ if rc := sess.renderCoordinator(); rc != nil {
742+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
743+ return
744+ }
745+ }
746+ if ac != nil {
747+ d.paint(sess, ac, reset)
748+ }
749+}
750+
751+// invalidateRenderNow publishes through the coordinator but immediately
752+// flushes the wake when the client can accept state. Attach uses this path so
753+// the required first full frame never depends on a debounce timer.
754+func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bool, producer string) {
755+ if sess != nil {
756+ if rc := sess.renderCoordinator(); rc != nil {
757+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
758+ rc.fireCurrent(false)
759+ return
760+ }
761+ }
762+ if ac != nil {
763+ d.paint(sess, ac, reset)
764+ }
765+}
766+
767+func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
768+ // Readiness probes enter the attachment send path, which may in turn read
769+ // coordinator metadata. Never hold c.mu across that external callback.
770+ c.mu.Lock()
771+ if !c.fireValidLocked(gen, watchdog) {
772+ c.mu.Unlock()
773+ return
774+ }
775+ // Welcome holds attachment.sendMu while its transport Send is in flight.
776+ // Do not probe ACK capacity through that lock before this incarnation is
777+ // ready: a deadline that expires during the handshake must complete and
778+ // leave work pending, rather than wedging behind Welcome.
779+ handshakePending := c.attachment != nil && !c.attachmentReady
780+ ackReady := c.opts.ackReady
781+ c.mu.Unlock()
782+ ready := !handshakePending && (ackReady == nil || ackReady())
783+
784+ // The readiness callback can detach, replace, or publish a sync batch.
785+ // syncGateOpenLocked retries predicate evaluation on every registry change
786+ // and returns holding c.mu, so pending consumption has no intervening gap.
787+ c.mu.Lock()
788+ if !c.fireValidLocked(gen, watchdog) {
789+ c.mu.Unlock()
790+ return
791+ }
792+ if !c.syncGateOpenLocked() {
793+ c.mu.Unlock()
794+ return
795+ }
796+ if !c.fireValidLocked(gen, watchdog) {
797+ c.mu.Unlock()
798+ return
799+ }
800+ attachmentEpoch := uint64(0)
801+ if c.attachment != nil {
802+ attachmentEpoch = c.attachmentEpoch
803+ }
804+ w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, attachment: c.attachment, attachmentEpoch: attachmentEpoch}
805+ // A detached target has no primary transport or ACK window. Its pending
806+ // state belongs exclusively to picker previews and must be consumed after
807+ // notification rather than accumulating ackDeferred forever.
808+ headlessPreviewOnly := c.detached && c.attachment == nil
809+ if !headlessPreviewOnly && (!ready || (c.attachment != nil && !c.attachmentReady)) {
810+ if !ready && deadline {
811+ c.ackDeferred = true
812+ }
813+ preview, previews := c.takePendingPreviewsLocked()
814+ c.mu.Unlock()
815+ c.notifyPreviews(w, preview, previews)
816+ return
817+ }
818+ preview, previews := c.takePendingPreviewsLocked()
819+ c.metrics.wakes.Add(1)
820+ c.metrics.coalesced.Add(uint64(w.coalesced))
821+ if !w.urgent {
822+ if w.coalesced > 1 {
823+ c.outputPressure += w.coalesced - 1
824+ if c.outputPressure > int((maxOutputRenderDeadline-minOutputRenderDeadline)/time.Millisecond) {
825+ c.outputPressure = int((maxOutputRenderDeadline - minOutputRenderDeadline) / time.Millisecond)
826+ }
827+ } else if c.outputPressure > 0 {
828+ c.outputPressure--
829+ }
830+ }
831+ timer := c.detachNormalTimerLocked()
832+ c.pending, c.pendingReset, c.pendingUrgent, c.ackDeferred, c.deadlineDue, c.coalesced, c.armed = false, false, false, false, false, 0, false
833+ wake := c.opts.wake
834+ c.mu.Unlock()
835+ stopTimer(timer)
836+ if wake != nil && !headlessPreviewOnly {
837+ wake(w)
838+ }
839+ c.notifyPreviews(w, preview, previews)
840+}
841+
842+func (c *renderCoordinator) fireValidLocked(gen uint64, watchdog bool) bool {
843+ return !c.torndown && c.pending && (watchdog || gen == c.generation)
844+}
845+
846+// syncGateOpen evaluates pane visibility without c.mu, then reacquires and
847+// validates the complete batch identity set before accepting that result.
848+// syncGateOpen evaluates visibility outside c.mu and returns with c.mu held
849+// only when the registry version still matches the snapshot. The caller must
850+// consume pending state before unlocking, eliminating the gate-to-consume gap.
851+func (c *renderCoordinator) syncGateOpenLocked() bool {
852+ for {
853+ version := c.syncRegistryVersion
854+ batches := make([]*syncBatch, 0, len(c.syncBatches))
855+ for _, batch := range c.syncBatches {
856+ batches = append(batches, batch)
857+ }
858+ c.mu.Unlock()
859+
860+ gated := false
861+ for _, batch := range batches {
862+ if batch.renderable == nil || batch.renderable() {
863+ gated = true
864+ break
865+ }
866+ }
867+ if hook := c.opts.afterSyncGateEvaluated; hook != nil {
868+ hook()
869+ }
870+
871+ c.mu.Lock()
872+ if version == c.syncRegistryVersion {
873+ return !gated
874+ }
875+ }
876+}
877+
878+// takePendingPreviewsLocked snapshots and consumes the preview delivery for
879+// this target generation. Unlike the target primary, this is never ACK-gated.
880+func (c *renderCoordinator) takePendingPreviewsLocked() (func(renderWake), []func(renderWake)) {
881+ if !c.pendingPreview {
882+ return nil, nil
883+ }
884+ c.pendingPreview = false
885+ previews := make([]func(renderWake), 0, len(c.previewWakes))
886+ for _, fn := range c.previewWakes {
887+ previews = append(previews, fn)
888+ }
889+ return c.previewWake, previews
890+}
891+
892+func (c *renderCoordinator) notifyPreviews(w renderWake, preview func(renderWake), previews []func(renderWake)) {
893+ if preview != nil {
894+ preview(w)
895+ }
896+ for _, fn := range previews {
897+ fn(w)
898+ }
899+}
A internal/usecase/daemon/render_coordinator_handshake_test.go +80 -0
@@ -0,0 +1,80 @@
1+package daemon
2+
3+import (
4+ "runtime"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// awaitHandshakeWorker observes the worker completion channel rather than a
12+// scheduler-dependent no-wake window. The generated mock timer's channel is
13+// the only event that advances it.
14+func awaitHandshakeWorker(t *testing.T, done <-chan struct{}) {
15+ t.Helper()
16+ for range 1_000_000 {
17+ select {
18+ case <-done:
19+ return
20+ default:
21+ runtime.Gosched()
22+ }
23+ }
24+ t.Fatal("coordinator deadline worker did not complete")
25+}
26+
27+// Fresh producer state may arrive after route has published an attachment but
28+// before the transport has accepted Welcome. It must remain pending until the
29+// exact attachment incarnation completes that handshake.
30+func TestRenderCoordinatorFreshWakeWaitsForWelcome(t *testing.T) {
31+ h := newCoordinatorHarness(t)
32+ ac := &attachedClient{}
33+ h.rc.attachWithReadiness(ac, false)
34+
35+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "pty"})
36+ timer := awaitCoordinatorScheduledTimer(t, h.clk)
37+ h.rc.mu.Lock()
38+ workerDone := h.rc.normalWorkerDone
39+ h.rc.mu.Unlock()
40+ require.NotNil(t, workerDone)
41+ timer.ch <- time.Time{}
42+ requireWorkerExit(t, workerDone)
43+ requireNoWake(t, h.wakes)
44+
45+ require.True(t, h.rc.markAttachmentReady(ac))
46+ h.rc.fireCurrent(false)
47+ wake := awaitWake(t, h.wakes)
48+ require.True(t, wake.reset)
49+ require.Equal(t, 1, wake.coalesced)
50+ require.Same(t, ac, wake.attachment)
51+ requireNoWake(t, h.wakes)
52+}
53+
54+// Parking and resuming the same client object is a new incarnation: a failed
55+// Welcome on the replacement transport must not revive readiness from before
56+// the park.
57+func TestRenderCoordinatorParkedResumeRequiresNewWelcome(t *testing.T) {
58+ h := newCoordinatorHarness(t)
59+ ac := &attachedClient{}
60+ h.rc.attachWithReadiness(ac, false)
61+ require.True(t, h.rc.markAttachmentReady(ac))
62+ h.rc.notePark(ac)
63+ h.rc.attachWithReadiness(ac, false)
64+
65+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "session"})
66+ timer := awaitCoordinatorScheduledTimer(t, h.clk)
67+ h.rc.mu.Lock()
68+ workerDone := h.rc.normalWorkerDone
69+ h.rc.mu.Unlock()
70+ require.NotNil(t, workerDone)
71+ timer.ch <- time.Time{}
72+ requireWorkerExit(t, workerDone)
73+ requireNoWake(t, h.wakes)
74+
75+ require.True(t, h.rc.markAttachmentReady(ac))
76+ h.rc.fireCurrent(false)
77+ wake := awaitWake(t, h.wakes)
78+ require.True(t, wake.reset)
79+ require.Equal(t, 1, wake.coalesced)
80+}
A internal/usecase/daemon/render_coordinator_publication_test.go +28 -0
@@ -0,0 +1,28 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestCoordinatorRejectsStaleAttachmentInvalidationAfterReplacement(t *testing.T) {
10+ var invalidations []renderInvalidation
11+ rc := newRenderCoordinator(renderCoordinatorOptions{
12+ onInvalidate: func(inv renderInvalidation) { invalidations = append(invalidations, inv) },
13+ })
14+ old := &attachedClient{}
15+ replacement := &attachedClient{}
16+ rc.attach(old)
17+ rc.noteReplace(old, replacement)
18+
19+ // This models a PR #71 resize callback that passed its sendMu generation
20+ // check immediately before a replacement was published.
21+ rc.invalidateForAttachment(old, renderInvalidation{producer: "stale resize"})
22+ require.Empty(t, invalidations)
23+
24+ // Pane/session producers carry no attachment and remain coordinator-owned.
25+ rc.invalidateForAttachment(nil, renderInvalidation{producer: "pane"})
26+ require.Len(t, invalidations, 1)
27+ require.Equal(t, "pane", invalidations[0].producer)
28+}
A internal/usecase/daemon/render_coordinator_test.go +1534 -0
@@ -0,0 +1,1534 @@
1+package daemon
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "runtime"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/internal/usecase/keys"
19+ "github.com/bnema/vev/internal/usecase/layout"
20+)
21+
22+// --- coordinator harness ------------------------------------------------------
23+
24+// coordinatorMockClock uses generated mocks while retaining deterministic timer
25+// channels and recorded deadlines for coordinator scheduling assertions.
26+type coordinatorMockClock struct {
27+ clock *portsmocks.MockClock
28+ timers chan *coordinatorMockTimer
29+ inert bool
30+}
31+
32+type coordinatorMockTimer struct {
33+ mock *portsmocks.MockTimer
34+ ch chan time.Time
35+ duration time.Duration
36+}
37+
38+func newCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockClock {
39+ t.Helper()
40+ clk := &coordinatorMockClock{
41+ clock: portsmocks.NewMockClock(t),
42+ timers: make(chan *coordinatorMockTimer, capacity),
43+ }
44+ clk.clock.EXPECT().Now().Return(time.Time{}).Maybe()
45+ clk.clock.EXPECT().NewTimer(mock.MatchedBy(func(d time.Duration) bool {
46+ return d == urgentRenderDeadline ||
47+ (d >= minOutputRenderDeadline && d <= maxOutputRenderDeadline) ||
48+ d == maxSyncUpdateDuration ||
49+ d == time.Second ||
50+ d == 15*time.Minute
51+ })).RunAndReturn(func(d time.Duration) ports.Timer {
52+ timer := &coordinatorMockTimer{
53+ mock: portsmocks.NewMockTimer(t),
54+ duration: d,
55+ }
56+ if !clk.inert {
57+ timer.ch = make(chan time.Time, 1)
58+ }
59+ // A normal deadline reads C both in its worker and in the inert-clock
60+ // guard; a watchdog reads it in its worker. The generated expectation
61+ // makes every channel access observable without a hand-written Timer.
62+ timer.mock.EXPECT().C().Maybe().Return((<-chan time.Time)(timer.ch))
63+ timer.mock.EXPECT().Stop().Maybe().Return(true)
64+ clk.timers <- timer
65+ return timer.mock
66+ }).Maybe()
67+ return clk
68+}
69+
70+func newInertCoordinatorMockClock(t *testing.T, capacity int) *coordinatorMockClock {
71+ clk := newCoordinatorMockClock(t, capacity)
72+ clk.inert = true
73+ return clk
74+}
75+
76+// TestRenderCoordinatorTimerCallbacksReenterCoordinator is deliberately a
77+// deadlock regression: every external timer method synchronously reads
78+// coordinator metadata. Before two-phase timer ownership this blocked while
79+// c.mu was held during normal arming, urgent replacement, sync arming, and
80+// detach cancellation.
81+func TestRenderCoordinatorTimerCallbacksReenterCoordinator(t *testing.T) {
82+ clock := portsmocks.NewMockClock(t)
83+ var rc *renderCoordinator
84+ clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
85+ timer := portsmocks.NewMockTimer(t)
86+ ch := make(chan time.Time)
87+ timer.EXPECT().C().Run(func() {
88+ _ = rc.resizeSnapshot()
89+ _ = rc.burstMetricsSnapshot()
90+ }).Return((<-chan time.Time)(ch)).Once()
91+ timer.EXPECT().Stop().Run(func() {
92+ _ = rc.resizeSnapshot()
93+ _ = rc.burstMetricsSnapshot()
94+ }).Return(true).Once()
95+ return timer
96+ }).Times(3)
97+ rc = newRenderCoordinator(renderCoordinatorOptions{clock: clock})
98+ ac := &attachedClient{}
99+ rc.attach(ac)
100+
101+ rc.invalidate(renderInvalidation{class: invalidateOutput}) // normal arm
102+ rc.invalidate(renderInvalidation{class: invalidateUrgent}) // urgent promotion
103+ rc.noteSyncBegin(nil, 1) // sync arm
104+ rc.noteDetach(ac) // cancel normal deadline only
105+ rc.noteSyncPaneRemoved(nil) // pane destruction cancels sync watchdog
106+}
107+
108+// coordinatorHarness wires one coordinator to generated clock/timer mocks and
109+// recording hooks. Every assertion is channel- or counter-based; nothing sleeps.
110+type coordinatorHarness struct {
111+ clk *coordinatorMockClock
112+ wakes chan renderWake
113+ previews chan renderWake
114+ ackReady atomic.Bool
115+ syncActive atomic.Bool
116+ rc *renderCoordinator
117+}
118+
119+func newCoordinatorHarness(t *testing.T) *coordinatorHarness {
120+ t.Helper()
121+ h := &coordinatorHarness{
122+ clk: newCoordinatorMockClock(t, 16),
123+ wakes: make(chan renderWake, 16),
124+ previews: make(chan renderWake, 16),
125+ }
126+ h.ackReady.Store(true)
127+ h.rc = newRenderCoordinator(renderCoordinatorOptions{
128+ clock: h.clk.clock,
129+ wake: func(w renderWake) { h.wakes <- w },
130+ ackReady: func() bool { return h.ackReady.Load() },
131+ syncActive: func() bool { return h.syncActive.Load() },
132+ })
133+ return h
134+}
135+
136+// armedTimers drains every deadline armed so far. Arming happens
137+// synchronously inside the coordinator call, so a non-blocking drain is
138+// deterministic.
139+func (h *coordinatorHarness) armedTimers(t *testing.T) []*coordinatorMockTimer {
140+ t.Helper()
141+ var timers []*coordinatorMockTimer
142+ for {
143+ select {
144+ case tm := <-h.clk.timers:
145+ timers = append(timers, tm)
146+ default:
147+ return timers
148+ }
149+ }
150+}
151+
152+// Coordinator calls and fake-clock advancement are synchronous test steps.
153+// These helpers deliberately never wait on wall time: a missing event is a
154+// deterministic contract failure, not a slow behavior to poll for.
155+func awaitWake(t *testing.T, ch chan renderWake) renderWake {
156+ t.Helper()
157+ for range 4096 {
158+ select {
159+ case w := <-ch:
160+ return w
161+ default:
162+ runtime.Gosched()
163+ }
164+ }
165+ t.Fatal("coordinator did not publish a wake after fake-clock advancement")
166+ return renderWake{}
167+}
168+
169+func requireNoWake(t *testing.T, ch chan renderWake) {
170+ t.Helper()
171+ select {
172+ case w := <-ch:
173+ t.Fatalf("unexpected coordinator wake: %+v", w)
174+ default:
175+ }
176+}
177+
178+func awaitInvalidation(t *testing.T, ch chan renderInvalidation) renderInvalidation {
179+ t.Helper()
180+ for range 4096 {
181+ select {
182+ case inv := <-ch:
183+ return inv
184+ default:
185+ runtime.Gosched()
186+ }
187+ }
188+ t.Fatal("producer did not publish a coordinator invalidation")
189+ return renderInvalidation{}
190+}
191+
192+func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) {
193+ t.Helper()
194+ select {
195+ case inv := <-ch:
196+ t.Fatalf("unexpected coordinator invalidation: %+v", inv)
197+ default:
198+ }
199+}
200+
201+func awaitCoordinatorScheduledTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {
202+ t.Helper()
203+ select {
204+ case tm := <-clk.timers:
205+ return tm
206+ default:
207+ t.Fatal("coordinator did not synchronously arm a fake-clock timer")
208+ return nil
209+ }
210+}
211+
212+func awaitLatestCoordinatorTimer(t *testing.T, clk *coordinatorMockClock) *coordinatorMockTimer {
213+ t.Helper()
214+ timer := awaitCoordinatorScheduledTimer(t, clk)
215+ for {
216+ select {
217+ case timer = <-clk.timers:
218+ default:
219+ return timer
220+ }
221+ }
222+}
223+
224+func requireNoCoordinatorOutputFrame(t *testing.T, sends chan ports.Frame) {
225+ t.Helper()
226+ select {
227+ case frame := <-sends:
228+ t.Fatalf("unexpected output frame: %+v", frame)
229+ default:
230+ }
231+}
232+
233+// --- wake coalescing and deadlines --------------------------------------------
234+
235+func TestRenderCoordinatorCoalescesWakesUnderOneDeadline(t *testing.T) {
236+ cases := []struct {
237+ name string
238+ invalidations []renderInvalidation
239+ wantTimers int
240+ wantMin time.Duration
241+ wantMax time.Duration
242+ wantWake renderWake
243+ }{
244+ {
245+ name: "urgent transition wakes within two milliseconds",
246+ invalidations: []renderInvalidation{{class: invalidateUrgent, reset: true, producer: "input.go"}},
247+ wantTimers: 1,
248+ wantMax: urgentRenderDeadline,
249+ wantWake: renderWake{reset: true, urgent: true, coalesced: 1},
250+ },
251+ {
252+ name: "output burst coalesces into one adaptive deadline",
253+ invalidations: []renderInvalidation{
254+ {class: invalidateOutput, producer: "render.go"},
255+ {class: invalidateOutput, producer: "render.go"},
256+ {class: invalidateOutput, producer: "render.go"},
257+ },
258+ wantTimers: 1,
259+ wantMin: minOutputRenderDeadline,
260+ wantMax: maxOutputRenderDeadline,
261+ wantWake: renderWake{coalesced: 3},
262+ },
263+ {
264+ name: "full redraw stays sticky across later incremental transitions",
265+ invalidations: []renderInvalidation{
266+ {class: invalidateOutput},
267+ {class: invalidateOutput, reset: true},
268+ {class: invalidateOutput},
269+ },
270+ wantTimers: 1,
271+ wantMin: minOutputRenderDeadline,
272+ wantMax: maxOutputRenderDeadline,
273+ wantWake: renderWake{reset: true, coalesced: 3},
274+ },
275+ {
276+ name: "urgent transition tightens a pending output deadline",
277+ invalidations: []renderInvalidation{
278+ {class: invalidateOutput},
279+ {class: invalidateUrgent},
280+ },
281+ wantTimers: 2,
282+ wantMax: urgentRenderDeadline,
283+ wantWake: renderWake{urgent: true, coalesced: 2},
284+ },
285+ }
286+ for _, tc := range cases {
287+ t.Run(tc.name, func(t *testing.T) {
288+ h := newCoordinatorHarness(t)
289+ for _, inv := range tc.invalidations {
290+ h.rc.invalidate(inv)
291+ }
292+ timers := h.armedTimers(t)
293+ require.Lenf(t, timers, tc.wantTimers,
294+ "cap-1 coalescing must arm exactly %d deadline timer(s) for %d invalidations",
295+ tc.wantTimers, len(tc.invalidations))
296+ last := timers[len(timers)-1]
297+ require.GreaterOrEqual(t, last.duration, tc.wantMin)
298+ require.LessOrEqual(t, last.duration, tc.wantMax)
299+
300+ last.ch <- time.Time{}
301+ require.Equal(t, tc.wantWake, awaitWake(t, h.wakes),
302+ "one wake must deliver the latest coalesced state")
303+ requireNoWake(t, h.wakes)
304+ })
305+ }
306+}
307+
308+func TestRenderCoordinatorAdaptsOutputDeadlineAfterBurstAndDecays(t *testing.T) {
309+ h := newCoordinatorHarness(t)
310+
311+ // A burst establishes pressure without extending its already-armed first
312+ // deadline; the following output deadline grows to the 16ms ceiling.
313+ for range 9 {
314+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
315+ }
316+ burst := h.armedTimers(t)
317+ require.Len(t, burst, 1)
318+ require.Equal(t, minOutputRenderDeadline, burst[0].duration)
319+ burst[0].ch <- time.Time{}
320+ awaitWake(t, h.wakes)
321+
322+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
323+ atCap := h.armedTimers(t)
324+ require.Len(t, atCap, 1)
325+ require.Equal(t, maxOutputRenderDeadline, atCap[0].duration,
326+ "burst pressure must be capped at the 16ms output deadline")
327+ atCap[0].ch <- time.Time{}
328+ awaitWake(t, h.wakes)
329+
330+ // Quiet singleton batches decay pressure toward the 8ms floor.
331+ var previous = maxOutputRenderDeadline
332+ for range 8 {
333+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
334+ timers := h.armedTimers(t)
335+ require.Len(t, timers, 1)
336+ require.LessOrEqual(t, timers[0].duration, previous)
337+ require.GreaterOrEqual(t, timers[0].duration, minOutputRenderDeadline)
338+ previous = timers[0].duration
339+ timers[0].ch <- time.Time{}
340+ awaitWake(t, h.wakes)
341+ }
342+ require.Equal(t, minOutputRenderDeadline, previous)
343+}
344+
345+func TestRenderCoordinatorUrgentDeadlineCannotBeExtended(t *testing.T) {
346+ h := newCoordinatorHarness(t)
347+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
348+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
349+ for range 32 {
350+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
351+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
352+ }
353+ timers := h.armedTimers(t)
354+ require.Len(t, timers, 2, "only the urgent promotion may replace the output timer")
355+ require.Equal(t, urgentRenderDeadline, timers[len(timers)-1].duration)
356+}
357+
358+// --- ACK gating ----------------------------------------------------------------
359+
360+func TestRenderCoordinatorAckReadinessReentersWithoutBlockingResize(t *testing.T) {
361+ owner := &attachedClient{}
362+ wakes := make(chan renderWake, 1)
363+ ackEntered := make(chan struct{})
364+ resizeDone := make(chan uint64, 1)
365+ fireDone := make(chan struct{})
366+
367+ var rc *renderCoordinator
368+ rc = newRenderCoordinator(renderCoordinatorOptions{
369+ ackReady: func() bool {
370+ close(ackEntered)
371+ // The readiness probe models the output send path reading coordinator
372+ // metadata after sendMu is held. It must never run under c.mu.
373+ _ = rc.resizeSnapshot()
374+ return true
375+ },
376+ wake: func(w renderWake) { wakes <- w },
377+ })
378+ rc.attach(owner)
379+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
380+
381+ go func() {
382+ rc.fire(1, false, true)
383+ close(fireDone)
384+ }()
385+ for range 4096 {
386+ select {
387+ case <-ackEntered:
388+ goto resize
389+ default:
390+ runtime.Gosched()
391+ }
392+ }
393+ t.Fatal("ack readiness was not evaluated")
394+
395+resize:
396+ go func() { resizeDone <- rc.recordResizeRequest(domain.Size{Cols: 120, Rows: 40}, owner) }()
397+ for range 4096 {
398+ select {
399+ case epoch := <-resizeDone:
400+ require.Equal(t, uint64(1), epoch)
401+ wake := awaitWake(t, wakes)
402+ require.Same(t, owner, wake.attachment)
403+ wake.attachment = nil
404+ wake.attachmentEpoch = 0
405+ require.Equal(t, renderWake{coalesced: 1}, wake)
406+ for range 4096 {
407+ select {
408+ case <-fireDone:
409+ return
410+ default:
411+ runtime.Gosched()
412+ }
413+ }
414+ t.Fatal("fire did not return after wake")
415+ default:
416+ runtime.Gosched()
417+ }
418+ }
419+ t.Fatal("ack readiness re-entry blocked resize metadata")
420+}
421+
422+func TestRenderCoordinatorAckDoesNotBypassAnUnexpiredDeadline(t *testing.T) {
423+ h := newCoordinatorHarness(t)
424+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
425+ timer := awaitCoordinatorScheduledTimer(t, h.clk)
426+ require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline)
427+
428+ // An unrelated ACK must not turn a normal output deadline into an
429+ // immediate wake or cancel its timer.
430+ h.rc.notifyAck()
431+ requireNoWake(t, h.wakes)
432+ timer.mock.AssertNotCalled(t, "Stop")
433+
434+ timer.ch <- time.Time{}
435+ awaitWake(t, h.wakes)
436+ requireNoWake(t, h.wakes)
437+}
438+
439+func TestRenderCoordinatorAckFlushesOnlyExpiredAckDeferredWork(t *testing.T) {
440+ t.Run("expired deadline flushes exactly once after readiness", func(t *testing.T) {
441+ h := newCoordinatorHarness(t)
442+ h.ackReady.Store(false)
443+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true, producer: "render.go"})
444+ timer := awaitCoordinatorScheduledTimer(t, h.clk)
445+ timer.ch <- time.Time{}
446+ requireNoWake(t, h.wakes)
447+
448+ // Repeated ACKs while capacity remains unavailable are no-ops.
449+ h.rc.notifyAck()
450+ requireNoWake(t, h.wakes)
451+ h.ackReady.Store(true)
452+ h.rc.notifyAck()
453+ w := awaitWake(t, h.wakes)
454+ require.True(t, w.reset)
455+ requireNoWake(t, h.wakes)
456+ h.rc.notifyAck()
457+ requireNoWake(t, h.wakes)
458+ })
459+
460+ t.Run("lifecycle clears deferred work and urgent explicit fires stay immediate", func(t *testing.T) {
461+ h := newCoordinatorHarness(t)
462+ h.ackReady.Store(false)
463+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
464+ timer := awaitCoordinatorScheduledTimer(t, h.clk)
465+ timer.ch <- time.Time{}
466+ requireNoWake(t, h.wakes)
467+ h.rc.noteSessionTeardown()
468+ h.ackReady.Store(true)
469+ h.rc.notifyAck()
470+ requireNoWake(t, h.wakes)
471+
472+ immediate := newCoordinatorHarness(t)
473+ immediate.rc.invalidate(renderInvalidation{class: invalidateUrgent})
474+ immediate.rc.fireCurrent(false)
475+ w := awaitWake(t, immediate.wakes)
476+ require.True(t, w.urgent)
477+ })
478+}
479+
480+func TestRenderCoordinatorAckGateBlocksCompositionUntilAck(t *testing.T) {
481+ h := newCoordinatorHarness(t)
482+ h.ackReady.Store(false)
483+
484+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
485+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
486+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
487+ for _, timer := range h.armedTimers(t) {
488+ timer.ch <- time.Time{}
489+ }
490+ requireNoWake(t, h.wakes)
491+
492+ h.ackReady.Store(true)
493+ h.rc.notifyAck()
494+ w := awaitWake(t, h.wakes)
495+ require.True(t, w.reset, "the deferred full redraw must stay sticky through the ack gate")
496+ require.Equal(t, 3, w.coalesced, "the post-ack wake must carry every deferred transition")
497+ requireNoWake(t, h.wakes)
498+
499+ h.rc.notifyAck()
500+ requireNoWake(t, h.wakes)
501+}
502+
503+// --- synchronized output ---------------------------------------------------------
504+
505+func TestRenderCoordinatorConcurrentSynchronizedPaneBatches(t *testing.T) {
506+ t.Run("end orders, watchdogs, and stale callbacks are isolated per pane", func(t *testing.T) {
507+ h := newCoordinatorHarness(t)
508+ firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1)
509+ firstPane, secondPane := &pane{}, &pane{}
510+
511+ // Distinct stable pane owners must remain independent.
512+ h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
513+ firstWatchdog := h.armedTimers(t)[0]
514+ h.rc.noteSyncBegin(secondPane, 22, func() { secondForced <- struct{}{} })
515+ secondWatchdog := h.armedTimers(t)[0]
516+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
517+
518+ // Completing either pane leaves the aggregate gate closed.
519+ h.rc.noteSyncEnd(firstPane, 11)
520+ requireNoWake(t, h.wakes)
521+ // A stale callback for the completed pane must not force it or wake.
522+ firstWatchdog.ch <- time.Time{}
523+ select {
524+ case <-firstForced:
525+ t.Fatal("stale watchdog forced a completed pane")
526+ default:
527+ }
528+ requireNoWake(t, h.wakes)
529+
530+ // The remaining pane's watchdog forces only its own batch, then the
531+ // aggregate completion produces exactly one urgent wake.
532+ secondWatchdog.ch <- time.Time{}
533+ <-secondForced
534+ w := awaitWake(t, h.wakes)
535+ require.True(t, w.watchdog)
536+ require.True(t, w.urgent, "aggregate completion must wake urgently")
537+ requireNoWake(t, h.wakes)
538+ })
539+
540+ t.Run("later pane can end first without releasing an earlier pane", func(t *testing.T) {
541+ h := newCoordinatorHarness(t)
542+ firstForced := make(chan struct{}, 1)
543+ firstPane, secondPane := &pane{}, &pane{}
544+ h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
545+ firstWatchdog := h.armedTimers(t)[0]
546+ h.rc.noteSyncBegin(secondPane, 22)
547+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
548+
549+ h.rc.noteSyncEnd(secondPane, 22)
550+ requireNoWake(t, h.wakes)
551+ firstWatchdog.ch <- time.Time{}
552+ <-firstForced
553+ w := awaitWake(t, h.wakes)
554+ require.True(t, w.watchdog)
555+ require.True(t, w.urgent, "final pane completion must wake urgently")
556+ requireNoWake(t, h.wakes)
557+ })
558+
559+ t.Run("pane removal cancels only that pane watchdog", func(t *testing.T) {
560+ h := newCoordinatorHarness(t)
561+ firstPane, secondPane := &pane{}, &pane{}
562+ firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1)
563+ h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} })
564+ firstWatchdog := h.armedTimers(t)[0]
565+ h.rc.noteSyncBegin(secondPane, 22, func() { secondForced <- struct{}{} })
566+ secondWatchdog := h.armedTimers(t)[0]
567+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
568+
569+ h.rc.noteSyncPaneRemoved(firstPane)
570+ firstWatchdog.ch <- time.Time{}
571+ select {
572+ case <-firstForced:
573+ t.Fatal("removed pane watchdog forced output")
574+ default:
575+ }
576+ requireNoWake(t, h.wakes)
577+ secondWatchdog.ch <- time.Time{}
578+ <-secondForced
579+ require.True(t, awaitWake(t, h.wakes).watchdog)
580+ })
581+}
582+
583+func TestRenderCoordinatorSyncGateFollowsDynamicRenderability(t *testing.T) {
584+ t.Run("inactive batch that becomes active gates activation output until end", func(t *testing.T) {
585+ h := newCoordinatorHarness(t)
586+ var visible atomic.Bool
587+ p := &pane{}
588+ h.rc.noteSyncBeginWithRenderability(p, 1, visible.Load)
589+ visible.Store(true)
590+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "activation"})
591+ for _, timer := range h.armedTimers(t) {
592+ timer.ch <- time.Time{}
593+ }
594+ requireNoWake(t, h.wakes)
595+
596+ h.rc.noteSyncEnd(p, 1)
597+ w := awaitWake(t, h.wakes)
598+ require.True(t, w.urgent)
599+ requireNoWake(t, h.wakes)
600+ })
601+
602+ t.Run("hidden batch does not stall newly active output or wake on late end", func(t *testing.T) {
603+ h := newCoordinatorHarness(t)
604+ var visible atomic.Bool
605+ visible.Store(true)
606+ p := &pane{}
607+ h.rc.noteSyncBeginWithRenderability(p, 1, visible.Load)
608+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "batch"})
609+ visible.Store(false)
610+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "new-active-tab"})
611+ timers := h.armedTimers(t)
612+ require.NotEmpty(t, timers)
613+ timers[len(timers)-1].ch <- time.Time{}
614+ w := awaitWake(t, h.wakes)
615+ require.False(t, w.watchdog)
616+ h.rc.noteSyncEnd(p, 1)
617+ requireNoWake(t, h.wakes)
618+ })
619+}
620+
621+func TestRenderCoordinatorSyncGateRetriesWhenBatchPublishesDuringPredicate(t *testing.T) {
622+ h := newCoordinatorHarness(t)
623+ first, second := &pane{}, &pane{}
624+ gateEvaluated := make(chan struct{})
625+ releaseGate := make(chan struct{})
626+ var hookOnce sync.Once
627+ h.rc.opts.afterSyncGateEvaluated = func() {
628+ hookOnce.Do(func() {
629+ close(gateEvaluated)
630+ <-releaseGate
631+ })
632+ }
633+ h.rc.noteSyncBeginWithRenderability(first, 1, func() bool { return false })
634+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "test"})
635+
636+ fireDone := make(chan struct{})
637+ go func() {
638+ h.rc.fireCurrent(false)
639+ close(fireDone)
640+ }()
641+ <-gateEvaluated
642+ // This publication is deliberately between an open predicate result and
643+ // pending consumption. A stale gate result must be rejected and retried.
644+ h.rc.noteSyncBeginWithRenderability(second, 2, func() bool { return true })
645+ close(releaseGate)
646+ for range 4096 {
647+ select {
648+ case <-fireDone:
649+ goto fired
650+ default:
651+ runtime.Gosched()
652+ }
653+ }
654+ t.Fatal("fire did not finish after retrying the registry snapshot")
655+
656+fired:
657+ requireNoWake(t, h.wakes)
658+ h.rc.noteSyncEnd(second, 2)
659+ require.Equal(t, renderWake{urgent: true, coalesced: 1}, awaitWake(t, h.wakes))
660+}
661+
662+func TestRenderCoordinatorSynchronizedOutput(t *testing.T) {
663+ t.Run("completion flushes pending state in one wake", func(t *testing.T) {
664+ h := newCoordinatorHarness(t)
665+ h.syncActive.Store(true)
666+ h.rc.noteSyncBegin(nil, 1)
667+ watchdogs := h.armedTimers(t)
668+ require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
669+ require.Equal(t, maxSyncUpdateDuration, watchdogs[len(watchdogs)-1].duration)
670+
671+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
672+ timers := h.armedTimers(t)
673+ require.Len(t, timers, 1)
674+ h.rc.mu.Lock()
675+ normalWorkerDone := h.rc.normalWorkerDone
676+ h.rc.mu.Unlock()
677+ require.NotNil(t, normalWorkerDone)
678+ timers[0].ch <- time.Time{}
679+ requireWorkerExit(t, normalWorkerDone)
680+ requireNoWake(t, h.wakes)
681+
682+ h.syncActive.Store(false)
683+ h.rc.noteSyncEnd(nil, 1)
684+ w := awaitWake(t, h.wakes)
685+ require.True(t, w.reset)
686+ requireNoWake(t, h.wakes)
687+ })
688+
689+ t.Run("watchdog ends a wedged batch before it flushes", func(t *testing.T) {
690+ h := newCoordinatorHarness(t)
691+ h.syncActive.Store(true)
692+ forced := make(chan struct{}, 1)
693+ h.rc.noteSyncBegin(nil, 7, func() { forced <- struct{}{} })
694+ watchdogs := h.armedTimers(t)
695+ require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
696+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
697+
698+ watchdogs[0].ch <- time.Time{}
699+ <-forced
700+ w := awaitWake(t, h.wakes)
701+ require.True(t, w.watchdog, "a wedged synchronized batch must be force-flushed by the watchdog")
702+ requireNoWake(t, h.wakes)
703+ })
704+
705+ t.Run("watchdog retains gate while force closes the VT batch", func(t *testing.T) {
706+ h := newCoordinatorHarness(t)
707+ entered := make(chan struct{})
708+ release := make(chan struct{})
709+ h.rc.noteSyncBegin(nil, 1, func() {
710+ close(entered)
711+ <-release
712+ })
713+ watchdogs := h.armedTimers(t)
714+ require.NotEmpty(t, watchdogs)
715+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
716+ watchdogs[0].ch <- time.Time{}
717+ <-entered
718+
719+ // A concurrent deadline/fire sees the retained batch gate and cannot
720+ // publish the partial VT state while force is blocked.
721+ h.rc.fireCurrent(false)
722+ requireNoWake(t, h.wakes)
723+ close(release)
724+ w := awaitWake(t, h.wakes)
725+ require.True(t, w.watchdog)
726+ require.True(t, w.urgent)
727+ requireNoWake(t, h.wakes)
728+ })
729+
730+ t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) {
731+ h := newCoordinatorHarness(t)
732+ h.syncActive.Store(true)
733+ h.rc.noteSyncBegin(nil, 1)
734+ watchdogs := h.armedTimers(t)
735+ require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog")
736+ h.syncActive.Store(false)
737+ h.rc.noteSyncEnd(nil, 1)
738+
739+ watchdogs[0].ch <- time.Time{}
740+ requireNoWake(t, h.wakes)
741+ })
742+}
743+
744+// --- preview subscription --------------------------------------------------------
745+
746+func TestRenderCoordinatorPreviewSubscription(t *testing.T) {
747+ h := newCoordinatorHarness(t)
748+ h.rc.subscribePreview(func(w renderWake) { h.previews <- w })
749+
750+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
751+ timers := h.armedTimers(t)
752+ require.NotEmpty(t, timers, "invalidate must arm a deadline timer")
753+ timers[len(timers)-1].ch <- time.Time{}
754+ owner := awaitWake(t, h.wakes)
755+ preview := awaitWake(t, h.previews)
756+ require.Equal(t, owner, preview, "a subscribed preview observes the same coalesced wake")
757+ requireNoWake(t, h.previews)
758+
759+ h.rc.teardownPreview()
760+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
761+ timers = h.armedTimers(t)
762+ require.NotEmpty(t, timers, "invalidate must arm a deadline timer")
763+ timers[len(timers)-1].ch <- time.Time{}
764+ awaitWake(t, h.wakes)
765+ requireNoWake(t, h.previews)
766+}
767+
768+// --- lifecycle and stale callbacks -----------------------------------------------
769+
770+func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
771+ cases := []struct {
772+ name string
773+ attach bool
774+ }{
775+ {name: "attached cross-session target blocked on ack", attach: true},
776+ {name: "headless cross-session target blocked on ack"},
777+ }
778+ for _, tc := range cases {
779+ t.Run(tc.name, func(t *testing.T) {
780+ h := newCoordinatorHarness(t)
781+ h.ackReady.Store(false)
782+ if tc.attach {
783+ h.rc.attach(&attachedClient{})
784+ }
785+ viewer := &attachedClient{}
786+ h.rc.subscribePreviewFor(viewer, func(w renderWake) { h.previews <- w })
787+
788+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "target output"})
789+ timers := h.armedTimers(t)
790+ require.Len(t, timers, 1)
791+ timers[0].ch <- time.Time{}
792+
793+ preview := awaitWake(t, h.previews)
794+ if tc.attach {
795+ require.NotNil(t, preview.attachment)
796+ }
797+ preview.attachment = nil
798+ preview.attachmentEpoch = 0
799+ require.Equal(t, renderWake{coalesced: 1}, preview,
800+ "the viewer preview must receive target output without target ACK capacity")
801+ requireNoWake(t, h.wakes)
802+ requireNoWake(t, h.previews)
803+
804+ h.ackReady.Store(true)
805+ h.rc.notifyAck()
806+ wake := awaitWake(t, h.wakes)
807+ if tc.attach {
808+ require.NotNil(t, wake.attachment)
809+ }
810+ wake.attachment = nil
811+ wake.attachmentEpoch = 0
812+ require.Equal(t, renderWake{coalesced: 1}, wake,
813+ "the target primary frame remains pending for its own ACK")
814+ requireNoWake(t, h.previews)
815+ })
816+ }
817+}
818+
819+func TestRenderCoordinatorDetachedTargetPublishesPreviewOnlyInvalidations(t *testing.T) {
820+ h := newCoordinatorHarness(t)
821+ target, viewer := &attachedClient{}, &attachedClient{}
822+ h.ackReady.Store(false)
823+ h.rc.attach(target)
824+ h.rc.subscribePreviewFor(viewer, func(w renderWake) { h.previews <- w })
825+ h.rc.noteDetach(target)
826+
827+ // A detached target only accepts PTY/session-owned invalidations. The old
828+ // attachment remains stale and must not revive its render path.
829+ h.rc.invalidateForAttachment(target, renderInvalidation{class: invalidateOutput, producer: "stale attachment"})
830+ require.Empty(t, h.armedTimers(t))
831+
832+ for _, producer := range []string{"first PTY output", "second PTY output"} {
833+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: producer})
834+ timers := h.armedTimers(t)
835+ require.Len(t, timers, 1)
836+ timers[0].ch <- time.Time{}
837+ preview := awaitWake(t, h.previews)
838+ preview.attachment = nil
839+ preview.attachmentEpoch = 0
840+ require.Equal(t, renderWake{coalesced: 1}, preview)
841+ requireNoWake(t, h.wakes)
842+
843+ h.rc.mu.Lock()
844+ require.False(t, h.rc.pending)
845+ require.False(t, h.rc.pendingPreview)
846+ require.False(t, h.rc.ackDeferred)
847+ require.False(t, h.rc.deadlineDue)
848+ require.Zero(t, h.rc.coalesced)
849+ require.True(t, h.rc.detached)
850+ require.Nil(t, h.rc.attachment)
851+ h.rc.mu.Unlock()
852+ }
853+
854+ // A later attach retains the normal first-paint reset path.
855+ replacement := &attachedClient{}
856+ h.ackReady.Store(true)
857+ h.rc.attach(replacement)
858+ h.rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "first paint"})
859+ timers := h.armedTimers(t)
860+ require.Len(t, timers, 1)
861+ timers[0].ch <- time.Time{}
862+ wake := awaitWake(t, h.wakes)
863+ require.True(t, wake.reset)
864+ require.Same(t, replacement, wake.attachment)
865+}
866+
867+func TestRenderCoordinatorPreviewLifecycleDropsStaleTargetWakes(t *testing.T) {
868+ cases := []struct {
869+ name string
870+ transition func(*renderCoordinator, *attachedClient, *attachedClient)
871+ }{
872+ {"target detach", func(rc *renderCoordinator, target, _ *attachedClient) { rc.noteDetach(target) }},
873+ {"target replacement", func(rc *renderCoordinator, target, replacement *attachedClient) { rc.noteReplace(target, replacement) }},
874+ {"target teardown", func(rc *renderCoordinator, _ *attachedClient, _ *attachedClient) { rc.noteSessionTeardown() }},
875+ }
876+ for _, tc := range cases {
877+ t.Run(tc.name, func(t *testing.T) {
878+ h := newCoordinatorHarness(t)
879+ target, replacement, viewer := &attachedClient{}, &attachedClient{}, &attachedClient{}
880+ h.rc.attach(target)
881+ h.rc.subscribePreviewFor(viewer, func(w renderWake) { h.previews <- w })
882+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "target output"})
883+ stale := h.armedTimers(t)
884+ require.Len(t, stale, 1)
885+
886+ tc.transition(h.rc, target, replacement)
887+ stale[0].ch <- time.Time{}
888+ requireNoWake(t, h.wakes)
889+ requireNoWake(t, h.previews)
890+ })
891+ }
892+}
893+
894+func TestRenderCoordinatorPreviewSubscriptionsAreIndependent(t *testing.T) {
895+ h := newCoordinatorHarness(t)
896+ one, two := &attachedClient{}, &attachedClient{}
897+ first, second := make(chan renderWake, 1), make(chan renderWake, 1)
898+ h.rc.subscribePreviewFor(one, func(w renderWake) { first <- w })
899+ h.rc.subscribePreviewFor(two, func(w renderWake) { second <- w })
900+ h.rc.attach(&attachedClient{})
901+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "preview"})
902+ last := h.armedTimers(t)
903+ last[len(last)-1].ch <- time.Time{}
904+ awaitWake(t, first)
905+ awaitWake(t, second)
906+
907+ h.rc.teardownPreviewFor(one)
908+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "preview"})
909+ last = h.armedTimers(t)
910+ last[len(last)-1].ch <- time.Time{}
911+ requireNoWake(t, first)
912+ awaitWake(t, second)
913+}
914+
915+func TestRenderCoordinatorLifecycleDropsStaleWakes(t *testing.T) {
916+ cases := []struct {
917+ name string
918+ teardown func(rc *renderCoordinator, owner *attachedClient)
919+ }{
920+ {"detach", func(rc *renderCoordinator, owner *attachedClient) { rc.noteDetach(owner) }},
921+ {"park", func(rc *renderCoordinator, owner *attachedClient) { rc.notePark(owner) }},
922+ {"session teardown", func(rc *renderCoordinator, _ *attachedClient) { rc.noteSessionTeardown() }},
923+ }
924+ for _, tc := range cases {
925+ t.Run(tc.name, func(t *testing.T) {
926+ h := newCoordinatorHarness(t)
927+ owner := &attachedClient{}
928+ h.rc.attach(owner)
929+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
930+ stale := h.armedTimers(t)
931+ require.NotEmpty(t, stale, "invalidate must arm a deadline timer")
932+
933+ tc.teardown(h.rc, owner)
934+ for _, timer := range stale {
935+ timer.ch <- time.Time{}
936+ }
937+ requireNoWake(t, h.wakes)
938+
939+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent})
940+ for _, timer := range h.armedTimers(t) {
941+ timer.ch <- time.Time{}
942+ }
943+ requireNoWake(t, h.wakes)
944+ })
945+ }
946+
947+ t.Run("replacement attachment wakes independently", func(t *testing.T) {
948+ h := newCoordinatorHarness(t)
949+ owner := &attachedClient{}
950+ replacement := &attachedClient{}
951+ h.rc.attach(owner)
952+ h.rc.invalidate(renderInvalidation{class: invalidateOutput})
953+ stale := h.armedTimers(t)
954+ require.NotEmpty(t, stale, "invalidate must arm a deadline timer")
955+
956+ h.rc.noteReplace(owner, replacement)
957+ h.rc.attach(replacement)
958+ for _, timer := range stale {
959+ timer.ch <- time.Time{}
960+ }
961+ requireNoWake(t, h.wakes)
962+
963+ h.rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true})
964+ fresh := h.armedTimers(t)
965+ require.NotEmpty(t, fresh, "a replacement attachment must arm its own deadline")
966+ fresh[len(fresh)-1].ch <- time.Time{}
967+ w := awaitWake(t, h.wakes)
968+ require.True(t, w.reset, "the replacement starts from an independent full state")
969+ requireNoWake(t, h.wakes)
970+ })
971+}
972+
973+// --- resize metadata ownership -----------------------------------------------------
974+
975+func TestRenderCoordinatorResizeMetadata(t *testing.T) {
976+ sz := func(cols, rows int) domain.Size { return domain.Size{Cols: cols, Rows: rows} }
977+ cases := []struct {
978+ name string
979+ run func(t *testing.T, rc *renderCoordinator, owner, replacement *attachedClient)
980+ }{
981+ {
982+ name: "latest request wins with strictly increasing epochs",
983+ run: func(t *testing.T, rc *renderCoordinator, owner, _ *attachedClient) {
984+ require.Equal(t, uint64(1), rc.recordResizeRequest(sz(100, 30), owner))
985+ require.Equal(t, uint64(2), rc.recordResizeRequest(sz(110, 32), owner))
986+ require.Equal(t, uint64(3), rc.recordResizeRequest(sz(120, 40), owner))
987+ snap := rc.resizeSnapshot()
988+ require.Equal(t, sz(120, 40), snap.size)
989+ require.Same(t, owner, snap.source)
990+ require.Equal(t, uint64(3), snap.epoch)
991+
992+ // Metadata ownership is independent of the retained PR #71
993+ // attachment dispatch state.
994+ owner.sendMu.Lock()
995+ generation, pending := owner.resizePaintGeneration, owner.resizePaintPending
996+ owner.sendMu.Unlock()
997+ require.Zero(t, generation, "recording metadata must not touch the PR #71 generation")
998+ require.False(t, pending, "recording metadata must not arm the PR #71 dispatch")
999+ },
1000+ },
1001+ {
1002+ name: "duplicate sizes still advance the epoch deterministically",
1003+ run: func(t *testing.T, rc *renderCoordinator, owner, _ *attachedClient) {
1004+ require.Equal(t, uint64(1), rc.recordResizeRequest(sz(120, 40), owner))
1005+ require.Equal(t, uint64(2), rc.recordResizeRequest(sz(120, 40), owner))
1006+ snap := rc.resizeSnapshot()
1007+ require.Equal(t, sz(120, 40), snap.size)
1008+ require.Equal(t, uint64(2), snap.epoch)
1009+ },
1010+ },
1011+ {
1012+ name: "stale resize callbacks preserve latest metadata through lifecycle transitions",
1013+ run: func(t *testing.T, rc *renderCoordinator, owner, replacement *attachedClient) {
1014+ type lifecycleCase struct {
1015+ name string
1016+ installNew bool
1017+ }
1018+ for _, tc := range []lifecycleCase{
1019+ {name: "detach"},
1020+ {name: "park"},
1021+ {name: "session teardown"},
1022+ {name: "replacement", installNew: true},
1023+ } {
1024+ t.Run(tc.name, func(t *testing.T) {
1025+ h := newCoordinatorHarness(t)
1026+ staleOwner := &attachedClient{}
1027+ freshOwner := &attachedClient{}
1028+ h.rc.attach(staleOwner)
1029+ require.Equal(t, uint64(1), h.rc.recordResizeRequest(sz(120, 40), staleOwner))
1030+
1031+ // This models a retained PR #71 callback which captured the old
1032+ // attachment before lifecycle ownership changed.
1033+ staleCallback := func() uint64 {
1034+ return h.rc.recordResizeRequest(sz(80, 20), staleOwner)
1035+ }
1036+
1037+ switch tc.name {
1038+ case "detach":
1039+ h.rc.noteDetach(staleOwner)
1040+ case "park":
1041+ h.rc.notePark(staleOwner)
1042+ case "session teardown":
1043+ h.rc.noteSessionTeardown()
1044+ case "replacement":
1045+ h.rc.noteReplace(staleOwner, freshOwner)
1046+ h.rc.attach(freshOwner)
1047+ require.Equal(t, uint64(2), h.rc.recordResizeRequest(sz(100, 50), freshOwner))
1048+ }
1049+
1050+ require.Zero(t, staleCallback(), "stale callback must not advance the resize epoch")
1051+ snap := h.rc.resizeSnapshot()
1052+ if tc.installNew {
1053+ require.Equal(t, sz(100, 50), snap.size)
1054+ require.Equal(t, uint64(2), snap.epoch)
1055+ require.Same(t, freshOwner, snap.source)
1056+ return
1057+ }
1058+ require.Equal(t, sz(120, 40), snap.size)
1059+ require.Equal(t, uint64(1), snap.epoch)
1060+ require.Same(t, staleOwner, snap.source,
1061+ "the stale callback must not replace the recorded attachment identity")
1062+ })
1063+ }
1064+ },
1065+ },
1066+ }
1067+ for _, tc := range cases {
1068+ t.Run(tc.name, func(t *testing.T) {
1069+ h := newCoordinatorHarness(t)
1070+ owner := &attachedClient{}
1071+ replacement := &attachedClient{}
1072+ h.rc.attach(owner)
1073+ tc.run(t, h.rc, owner, replacement)
1074+ })
1075+ }
1076+}
1077+
1078+// --- producer fan-in ------------------------------------------------------------
1079+
1080+// producerFiles is the current production direct-paint inventory. Every file
1081+// gets exactly one exercised state transition in TestProducerInvalidations.
1082+var producerFiles = []string{
1083+ "attention.go", "client.go", "copymode.go", "floating.go", "input.go",
1084+ "palette.go", "pane_actions.go", "picker.go", "prompt.go", "render.go",
1085+ "session.go", "session_back.go",
1086+}
1087+
1088+func TestProducerInvalidations(t *testing.T) {
1089+ cases := []struct {
1090+ file string
1091+ name string
1092+ tabs int
1093+ run func(t *testing.T, d *Daemon, sess *session, ac *attachedClient)
1094+ }{
1095+ {
1096+ file: "attention.go",
1097+ name: "attention repaint",
1098+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1099+ d.repaintAttachedClients(sess)
1100+ },
1101+ },
1102+ {
1103+ file: "client.go",
1104+ name: "client theme application",
1105+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1106+ d.applyTheme(sess, ac, ports.Theme{TrueColor: true})
1107+ },
1108+ },
1109+ {
1110+ file: "copymode.go",
1111+ name: "copy mode entry",
1112+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1113+ d.enterCopyMode(sess, ac)
1114+ },
1115+ },
1116+ {
1117+ file: "floating.go",
1118+ name: "floating toggle to visible",
1119+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1120+ fp := newPaneWithStableID(layout.PaneID("floating"), "float-producer", newQuietPTY(), domain.Size{Cols: 40, Rows: 10})
1121+ tb := sess.activeTab()
1122+ tb.mu.Lock()
1123+ tb.floating.pane = fp
1124+ tb.floating.state = floatingHidden
1125+ tb.floating.generation = 1
1126+ tb.mu.Unlock()
1127+ require.NoError(t, d.toggleFloating(sess, ac))
1128+ },
1129+ },
1130+ {
1131+ file: "input.go",
1132+ name: "switch tab key action",
1133+ tabs: 2,
1134+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1135+ daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionSwitchTab2)
1136+ require.Equal(t, 1, activeTabIndex(sess))
1137+ },
1138+ },
1139+ {
1140+ file: "palette.go",
1141+ name: "palette entry",
1142+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1143+ d.enterPalette(sess, ac)
1144+ },
1145+ },
1146+ {
1147+ file: "pane_actions.go",
1148+ name: "pane split",
1149+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1150+ require.NoError(t, d.splitPane(sess, ac, layout.Right))
1151+ },
1152+ },
1153+ {
1154+ file: "picker.go",
1155+ name: "picker entry",
1156+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1157+ d.enterPicker(sess, ac)
1158+ },
1159+ },
1160+ {
1161+ file: "prompt.go",
1162+ name: "prompt entry",
1163+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1164+ d.enterPrompt(sess, ac, "rename", "", func(string) error { return nil })
1165+ },
1166+ },
1167+ {
1168+ file: "render.go",
1169+ name: "retained resize dispatch",
1170+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1171+ clk := newCoordinatorMockClock(t, 2)
1172+ d.clock = clk.clock
1173+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 26})
1174+ awaitCoordinatorScheduledTimer(t, clk).ch <- time.Time{}
1175+ },
1176+ },
1177+ {
1178+ file: "session.go",
1179+ name: "tab close repaint",
1180+ tabs: 2,
1181+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1182+ sess.mu.Lock()
1183+ tb := sess.tabs[1]
1184+ sess.mu.Unlock()
1185+ d.closeTab(sess, tb, true)
1186+ },
1187+ },
1188+ {
1189+ file: "session_back.go",
1190+ name: "back session fallback without a target",
1191+ run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1192+ d.backSession(sess, ac)
1193+ require.Same(t, sess, ac.currentSession())
1194+ },
1195+ },
1196+ }
1197+
1198+ exercised := make([]string, 0, len(cases))
1199+ for _, tc := range cases {
1200+ exercised = append(exercised, tc.file)
1201+ }
1202+ require.ElementsMatch(t, producerFiles, exercised,
1203+ "every current direct-paint producer file needs exactly one exercised transition")
1204+
1205+ for _, tc := range cases {
1206+ t.Run(tc.file+"/"+tc.name, func(t *testing.T) {
1207+ tabs := tc.tabs
1208+ if tabs == 0 {
1209+ tabs = 1
1210+ }
1211+ d, sess, ac, sends, releases := newManualTabSession(t, tabs)
1212+ defer releaseAll(releases)
1213+ invs := make(chan renderInvalidation, 8)
1214+ sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
1215+ clock: d.clock,
1216+ wake: func(renderWake) {},
1217+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
1218+ }))
1219+
1220+ tc.run(t, d, sess, ac)
1221+
1222+ awaitInvalidation(t, invs)
1223+ requireNoInvalidation(t, invs)
1224+ requireNoCoordinatorOutputFrame(t, sends)
1225+ })
1226+ }
1227+}
1228+
1229+// TestProducerInvalidationInventory is a supplementary inventory guard only:
1230+// it keeps producerFiles aligned with the daemon source layout so the
1231+// behavioral table above cannot drift silently. It is never acceptance
1232+// evidence for coordinator behavior.
1233+func TestProducerInvalidationInventory(t *testing.T) {
1234+ for _, name := range producerFiles {
1235+ _, err := os.Stat(filepath.Join(".", name))
1236+ require.NoErrorf(t, err, "producer file %s is gone; update TestProducerInvalidations", name)
1237+ }
1238+}
1239+
1240+// --- retained PR #71 resize dispatch ----------------------------------------------
1241+
1242+func TestConcurrentPaintInitializesOverlayUnderSendOwnership(t *testing.T) {
1243+ p, release := newBlockingPTY(t)
1244+ defer release()
1245+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1246+ // Exercise the original seam: two fallback paints reach lazy initialization
1247+ // together before either can compose.
1248+ ac.overlays = nil
1249+ ac.overlayOnce = sync.Once{}
1250+ start := make(chan struct{})
1251+ var wg sync.WaitGroup
1252+ wg.Add(2)
1253+ for range 2 {
1254+ go func() {
1255+ defer wg.Done()
1256+ <-start
1257+ d.paint(sess, ac, true)
1258+ }()
1259+ }
1260+ close(start)
1261+ wg.Wait()
1262+ require.NotNil(t, ac.overlays)
1263+}
1264+
1265+func TestStartPaneGoroutinesAccountsForOneReader(t *testing.T) {
1266+ p, release := newBlockingPTY(t)
1267+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
1268+ tb := sess.activeTab()
1269+ require.NotNil(t, tb)
1270+ d.startPaneGoroutines(sess, tb, tb.focusedPane())
1271+ release()
1272+ select {
1273+ case <-waitGroupDone(&d.sessWg):
1274+ case <-time.After(time.Second):
1275+ t.Fatal("one reader must balance exactly one WaitGroup count")
1276+ }
1277+}
1278+
1279+func requireWorkerExit(t *testing.T, done <-chan struct{}) {
1280+ t.Helper()
1281+ for range 4096 {
1282+ select {
1283+ case <-done:
1284+ return
1285+ default:
1286+ runtime.Gosched()
1287+ }
1288+ }
1289+ t.Fatal("cancelled coordinator timer worker did not exit")
1290+}
1291+
1292+func TestCoordinatorDeadlineCannotPaintPublishedReplacementBeforeOwnershipInstall(t *testing.T) {
1293+ p, releasePTY := newBlockingPTY(t)
1294+ defer releasePTY()
1295+ d, sess, owner, ownerSends := newManualSessionWithPTYs(t, p)
1296+ replacementTransport, replacementSends := newCapturingTransport(t)
1297+ replacement := &attachedClient{tr: replacementTransport, output: newOutputStateStream(), size: owner.size}
1298+ replacement.initOverlays()
1299+ replacement.setSession(sess)
1300+
1301+ clock := newCoordinatorMockClock(t, 2)
1302+ d.clock = clock.clock
1303+ rc := newRenderCoordinator(renderCoordinatorOptions{
1304+ clock: clock.clock,
1305+ wake: func(w renderWake) {
1306+ // This is the production ownership boundary: composition must use
1307+ // the coordinator's captured attachment, never sess.client.
1308+ d.paint(sess, w.attachment, w.reset)
1309+ },
1310+ ackReady: func() bool { return true },
1311+ })
1312+ rc.attach(owner)
1313+ sess.installRenderCoordinator(rc)
1314+ rc.invalidate(renderInvalidation{class: invalidateOutput, reset: true})
1315+ timer := awaitCoordinatorScheduledTimer(t, clock)
1316+
1317+ // Model attachClient's publication window exactly: sess.client is new,
1318+ // but coordinator replacement has not yet invalidated the old deadline.
1319+ sess.mu.Lock()
1320+ sess.client = replacement
1321+ sess.mu.Unlock()
1322+ timer.ch <- time.Time{}
1323+ requireNoCoordinatorOutputFrame(t, replacementSends)
1324+ requireNoCoordinatorOutputFrame(t, ownerSends)
1325+}
1326+
1327+func TestRenderCoordinatorInertTimerFiresSynchronouslyWithoutWorker(t *testing.T) {
1328+ clock := portsmocks.NewMockClock(t)
1329+ timer := portsmocks.NewMockTimer(t)
1330+ clock.EXPECT().NewTimer(minOutputRenderDeadline).Return(timer).Once()
1331+ timer.EXPECT().C().Return((<-chan time.Time)(nil)).Once()
1332+ timer.EXPECT().Stop().Return(true).Once()
1333+
1334+ wakes := make(chan renderWake, 1)
1335+ rc := newRenderCoordinator(renderCoordinatorOptions{
1336+ clock: clock,
1337+ ackReady: func() bool { return true },
1338+ wake: func(w renderWake) { wakes <- w },
1339+ })
1340+ rc.invalidate(renderInvalidation{class: invalidateOutput})
1341+
1342+ require.Equal(t, renderWake{coalesced: 1}, <-wakes)
1343+ require.Equal(t, renderCoordinatorBurstMetricsSnapshot{invalidations: 1, wakes: 1, coalesced: 1}, rc.burstMetricsSnapshot())
1344+ rc.mu.Lock()
1345+ require.Nil(t, rc.normalTimer)
1346+ require.Nil(t, rc.normalCancel)
1347+ require.Nil(t, rc.normalWorkerDone)
1348+ rc.mu.Unlock()
1349+}
1350+
1351+func TestRenderCoordinatorSyncBatchSurvivesAttachmentLifecycle(t *testing.T) {
1352+ t.Run("detach and park retain a gated headless preview until complete", func(t *testing.T) {
1353+ for _, transition := range []struct {
1354+ name string
1355+ run func(*renderCoordinator, *attachedClient)
1356+ }{
1357+ {"detach", func(rc *renderCoordinator, ac *attachedClient) { rc.noteDetach(ac) }},
1358+ {"park", func(rc *renderCoordinator, ac *attachedClient) { rc.notePark(ac) }},
1359+ } {
1360+ t.Run(transition.name, func(t *testing.T) {
1361+ h := newCoordinatorHarness(t)
1362+ target, viewer, p := &attachedClient{}, &attachedClient{}, &pane{}
1363+ h.rc.attach(target)
1364+ h.rc.subscribePreviewFor(viewer, func(w renderWake) { h.previews <- w })
1365+ h.rc.noteSyncBegin(p, 1)
1366+ watchdog := h.armedTimers(t)[0]
1367+ transition.run(h.rc, target)
1368+
1369+ // PTY-owned output remains visible to picker observers while the
1370+ // target attachment is parked. Its deadline must retain the sync gate.
1371+ h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "complete preview"})
1372+ timers := h.armedTimers(t)
1373+ require.Len(t, timers, 1)
1374+ h.rc.mu.Lock()
1375+ normalWorkerDone := h.rc.normalWorkerDone
1376+ h.rc.mu.Unlock()
1377+ require.NotNil(t, normalWorkerDone)
1378+ timers[0].ch <- time.Time{}
1379+ requireWorkerExit(t, normalWorkerDone)
1380+ requireNoWake(t, h.previews)
1381+
1382+ h.rc.mu.Lock()
1383+ require.Same(t, h.rc.syncBatches[p].timer, watchdog.mock)
1384+ h.rc.mu.Unlock()
1385+ h.rc.noteSyncEnd(p, 1)
1386+ preview := awaitWake(t, h.previews)
1387+ preview.attachment = nil
1388+ preview.attachmentEpoch = 0
1389+ require.Equal(t, renderWake{urgent: true, coalesced: 1}, preview)
1390+ requireNoWake(t, h.previews)
1391+ requireNoWake(t, h.wakes)
1392+ })
1393+ }
1394+ })
1395+
1396+ t.Run("replacement first paint waits for active batch and rejects stale owner", func(t *testing.T) {
1397+ h := newCoordinatorHarness(t)
1398+ old, replacement, p := &attachedClient{}, &attachedClient{}, &pane{}
1399+ h.rc.attach(old)
1400+ h.rc.noteSyncBegin(p, 1)
1401+ watchdog := h.armedTimers(t)[0]
1402+ h.rc.noteReplace(old, replacement, false)
1403+ require.False(t, h.rc.markAttachmentReady(old), "old attachment Welcome is stale")
1404+ require.True(t, h.rc.markAttachmentReady(replacement))
1405+
1406+ h.rc.invalidateForAttachment(old, renderInvalidation{class: invalidateUrgent, producer: "stale old attachment"})
1407+ require.Empty(t, h.armedTimers(t))
1408+ h.rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"})
1409+ timers := h.armedTimers(t)
1410+ require.Len(t, timers, 1)
1411+ h.rc.mu.Lock()
1412+ normalWorkerDone := h.rc.normalWorkerDone
1413+ h.rc.mu.Unlock()
1414+ require.NotNil(t, normalWorkerDone)
1415+ timers[0].ch <- time.Time{}
1416+ requireWorkerExit(t, normalWorkerDone)
1417+ requireNoWake(t, h.wakes)
1418+
1419+ h.rc.mu.Lock()
1420+ require.Same(t, h.rc.syncBatches[p].timer, watchdog.mock)
1421+ h.rc.mu.Unlock()
1422+ h.rc.noteSyncEnd(p, 1)
1423+ wake := awaitWake(t, h.wakes)
1424+ require.True(t, wake.reset)
1425+ require.True(t, wake.urgent)
1426+ require.Same(t, replacement, wake.attachment)
1427+ requireNoWake(t, h.wakes)
1428+ })
1429+
1430+ t.Run("pane removal and session teardown cancel sync workers", func(t *testing.T) {
1431+ for _, lifecycle := range []struct {
1432+ name string
1433+ end func(*renderCoordinator, *pane)
1434+ }{
1435+ {"pane removal", func(rc *renderCoordinator, p *pane) { rc.noteSyncPaneRemoved(p) }},
1436+ {"session teardown", func(rc *renderCoordinator, _ *pane) { rc.noteSessionTeardown() }},
1437+ } {
1438+ t.Run(lifecycle.name, func(t *testing.T) {
1439+ clk := newInertCoordinatorMockClock(t, 2)
1440+ rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk.clock})
1441+ p := &pane{}
1442+ rc.noteSyncBegin(p, 1)
1443+ syncTimer := <-clk.timers
1444+ rc.mu.Lock()
1445+ syncDone := rc.syncBatches[p].done
1446+ rc.mu.Unlock()
1447+ lifecycle.end(rc, p)
1448+ syncTimer.mock.AssertNumberOfCalls(t, "Stop", 1)
1449+ requireWorkerExit(t, syncDone)
1450+ })
1451+ }
1452+ })
1453+}
1454+
1455+func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) {
1456+ newResizeFixture := func(t *testing.T) (*Daemon, *session, *attachedClient, chan ports.Frame, *coordinatorMockClock, chan renderInvalidation) {
1457+ t.Helper()
1458+ p, releasePTY := newBlockingPTY(t)
1459+ t.Cleanup(releasePTY)
1460+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1461+ clk := newCoordinatorMockClock(t, 4)
1462+ d.clock = clk.clock
1463+ invs := make(chan renderInvalidation, 4)
1464+ rc := newRenderCoordinator(renderCoordinatorOptions{
1465+ clock: clk.clock,
1466+ wake: func(renderWake) {},
1467+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
1468+ })
1469+ rc.attach(ac)
1470+ sess.installRenderCoordinator(rc)
1471+ return d, sess, ac, sends, clk, invs
1472+ }
1473+
1474+ t.Run("bounded timer records metadata and dispatches through the coordinator", func(t *testing.T) {
1475+ d, sess, ac, sends, clk, invs := newResizeFixture(t)
1476+
1477+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
1478+ timer := awaitCoordinatorScheduledTimer(t, clk)
1479+ require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline)
1480+ require.LessOrEqual(t, timer.duration, maxOutputRenderDeadline)
1481+ ac.sendMu.Lock()
1482+ generation, pending := ac.resizePaintGeneration, ac.resizePaintPending
1483+ ac.sendMu.Unlock()
1484+ require.Equal(t, uint64(1), generation, "PR #71 generation ownership must stay with the attachment")
1485+ require.True(t, pending, "PR #71 pending dispatch must remain armed")
1486+
1487+ snap := sess.renderCoordinator().resizeSnapshot()
1488+ require.Equal(t, domain.Size{Cols: 120, Rows: 24}, snap.size,
1489+ "the coordinator must record the latest requested resize before delegating")
1490+ require.Same(t, ac, snap.source)
1491+ require.Equal(t, uint64(1), snap.epoch)
1492+ requireNoCoordinatorOutputFrame(t, sends)
1493+
1494+ timer.ch <- time.Time{}
1495+ inv := awaitInvalidation(t, invs)
1496+ require.True(t, inv.reset, "the resize dispatch must request a full-redraw invalidation")
1497+ requireNoInvalidation(t, invs)
1498+ requireNoCoordinatorOutputFrame(t, sends)
1499+ })
1500+
1501+ t.Run("stale generations stay rejected", func(t *testing.T) {
1502+ d, sess, ac, sends, clk, invs := newResizeFixture(t)
1503+
1504+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 24})
1505+ first := awaitCoordinatorScheduledTimer(t, clk)
1506+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
1507+ latest := awaitCoordinatorScheduledTimer(t, clk)
1508+ require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().epoch,
1509+ "every resize request must advance the coordinator epoch")
1510+
1511+ first.ch <- time.Time{}
1512+ requireNoInvalidation(t, invs)
1513+ requireNoCoordinatorOutputFrame(t, sends)
1514+
1515+ latest.ch <- time.Time{}
1516+ inv := awaitInvalidation(t, invs)
1517+ require.True(t, inv.reset)
1518+ requireNoInvalidation(t, invs)
1519+ requireNoCoordinatorOutputFrame(t, sends)
1520+ require.Equal(t, domain.Size{Cols: 120, Rows: 24}, sess.renderCoordinator().resizeSnapshot().size)
1521+ })
1522+
1523+ t.Run("cancellation still drops the pending dispatch", func(t *testing.T) {
1524+ d, sess, ac, sends, clk, invs := newResizeFixture(t)
1525+
1526+ d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30})
1527+ timer := awaitCoordinatorScheduledTimer(t, clk)
1528+ ac.cancelResizePaint()
1529+
1530+ timer.ch <- time.Time{}
1531+ requireNoInvalidation(t, invs)
1532+ requireNoCoordinatorOutputFrame(t, sends)
1533+ })
1534+}
M internal/usecase/daemon/render_test.go +328 -393
@@ -110,6 +110,334 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
110 // stubClock returns timers whose channel never fires, so a scheduler under it110 // stubClock returns timers whose channel never fires, so a scheduler under it
111 // blocks in its debounce loop until the session context is cancelled. Used by111 // blocks in its debounce loop until the session context is cancelled. Used by
112 112
113+// channelPTY drives ptyReader through generated PTY mocks. A processed token is
114+// published only when ptyReader asks for its next read, which proves the
115+// preceding chunk completed its parser and coordinator path without sleeping.
116+type channelPTYStep struct {
117+ data []byte
118+ err error
119+}
120+
121+func newChannelPTY(t *testing.T) (*portsmocks.MockPTY, chan<- channelPTYStep, <-chan struct{}) {
122+ t.Helper()
123+ pty := portsmocks.NewMockPTY(t)
124+ steps := make(chan channelPTYStep)
125+ processed := make(chan struct{}, 8)
126+ read := false
127+ pty.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
128+ if read {
129+ processed <- struct{}{}
130+ }
131+ read = true
132+ step := <-steps
133+ return copy(buf, step.data), step.err
134+ }).Maybe()
135+ pty.EXPECT().Close().Return(nil).Maybe()
136+ return pty, steps, processed
137+}
138+
139+func awaitPTYReadProcessed(t *testing.T, processed <-chan struct{}) {
140+ t.Helper()
141+ <-processed
142+}
143+
144+func awaitOutputFrameWithoutSleep(t *testing.T, sends <-chan ports.Frame) ports.Frame {
145+ t.Helper()
146+ frame := <-sends
147+ require.Equal(t, ports.MsgOutput, frame.Type)
148+ return frame
149+}
150+
151+func drainCoordinatorTimers(clock *coordinatorMockClock) []*coordinatorMockTimer {
152+ var timers []*coordinatorMockTimer
153+ for {
154+ select {
155+ case timer := <-clock.timers:
156+ timers = append(timers, timer)
157+ default:
158+ return timers
159+ }
160+ }
161+}
162+
163+func fireCoordinatorTimer(t *testing.T, rc *renderCoordinator, timers []*coordinatorMockTimer, duration time.Duration) {
164+ t.Helper()
165+ for _, timer := range timers {
166+ if timer.duration != duration {
167+ continue
168+ }
169+ var done <-chan struct{}
170+ rc.mu.Lock()
171+ if rc.normalTimer == timer.mock {
172+ done = rc.normalWorkerDone
173+ }
174+ rc.mu.Unlock()
175+ timer.ch <- time.Time{}
176+ if done != nil {
177+ <-done
178+ }
179+ return
180+ }
181+ t.Fatalf("coordinator did not arm %s timer", duration)
182+}
183+
184+func TestPTYReaderSyncVisibilityTransitions(t *testing.T) {
185+ t.Run("inactive synchronized pane activates only after complete urgent frame", func(t *testing.T) {
186+ inactivePTY, inactiveSteps, inactiveProcessed := newChannelPTY(t)
187+ activePTY, _, _ := newChannelPTY(t)
188+ d, sess, ac, sends := newManualSessionWithPTYs(t, activePTY, inactivePTY)
189+ clock := newCoordinatorMockClock(t, 8)
190+ d.clock = clock.clock
191+ rc := d.attachCoordinator(sess, nil, ac, true)
192+
193+ d.sessWg.Add(1)
194+ go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
195+ inactiveSteps <- channelPTYStep{data: []byte("\x1b[?2026hpartial")}
196+ awaitPTYReadProcessed(t, inactiveProcessed)
197+
198+ sess.mu.Lock()
199+ sess.active = 1
200+ sess.mu.Unlock()
201+ d.invalidateRender(sess, ac, true, "sync activation")
202+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)
203+ requireNoCoordinatorOutputFrame(t, sends)
204+
205+ inactiveSteps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
206+ awaitPTYReadProcessed(t, inactiveProcessed)
207+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)
208+ frame := awaitOutputFrameWithoutSleep(t, sends)
209+ output, err := ports.UnmarshalOutput(frame.Payload)
210+ require.NoError(t, err)
211+ require.Contains(t, string(output.Data), "partial complete")
212+ requireNoCoordinatorOutputFrame(t, sends)
213+
214+ inactiveSteps <- channelPTYStep{err: io.EOF}
215+ d.sessWg.Wait()
216+ })
217+
218+ t.Run("hidden synchronized pane cannot stall newly active output or flush late", func(t *testing.T) {
219+ oldPTY, oldSteps, oldProcessed := newChannelPTY(t)
220+ newPTY, newSteps, newProcessed := newChannelPTY(t)
221+ parkedPTY, _, _ := newChannelPTY(t)
222+ // Keep one unopened tab so reader EOF cleanup cannot tear down the
223+ // session and introduce unrelated detach-notification timers.
224+ d, sess, ac, sends := newManualSessionWithPTYs(t, oldPTY, newPTY, parkedPTY)
225+ clock := newCoordinatorMockClock(t, 8)
226+ d.clock = clock.clock
227+ rc := d.attachCoordinator(sess, nil, ac, true)
228+
229+ d.sessWg.Add(2)
230+ go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
231+ go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
232+ oldSteps <- channelPTYStep{data: []byte("\x1b[?2026hold partial")}
233+ awaitPTYReadProcessed(t, oldProcessed)
234+
235+ sess.mu.Lock()
236+ sess.active = 1
237+ sess.mu.Unlock()
238+ newSteps <- channelPTYStep{data: []byte("newly active")}
239+ awaitPTYReadProcessed(t, newProcessed)
240+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline)
241+ frame := awaitOutputFrameWithoutSleep(t, sends)
242+ output, err := ports.UnmarshalOutput(frame.Payload)
243+ require.NoError(t, err)
244+ require.Contains(t, string(output.Data), "newly active")
245+ require.NotContains(t, string(output.Data), "old partial")
246+
247+ oldSteps <- channelPTYStep{data: []byte("\x1b[?2026l")}
248+ awaitPTYReadProcessed(t, oldProcessed)
249+ requireNoCoordinatorOutputFrame(t, sends)
250+
251+ oldSteps <- channelPTYStep{err: io.EOF}
252+ newSteps <- channelPTYStep{err: io.EOF}
253+ d.sessWg.Wait()
254+ })
255+}
256+
257+func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) {
258+ t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) {
259+ pty, steps, processed := newChannelPTY(t)
260+ d, target, targetClient, _ := newManualSessionWithPTYs(t, pty)
261+ clock := newCoordinatorMockClock(t, 8)
262+ d.clock = clock.clock
263+ rc := d.attachCoordinator(target, nil, targetClient, true)
264+
265+ // A viewer in a different session is previewing the headless target.
266+ viewer := &attachedClient{}
267+ viewer.initOverlays()
268+ viewer.overlays.pickerMu.Lock()
269+ viewer.overlays.pickerPreview = target.tabs[0]
270+ viewer.overlays.pickerMu.Unlock()
271+ d.sessions["viewer"] = &session{id: "viewer", client: viewer}
272+ previews := make(chan renderWake, 2)
273+ rc.subscribePreviewFor(viewer, func(w renderWake) { previews <- w })
274+
275+ d.sessWg.Add(1)
276+ go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
277+ steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")}
278+ awaitPTYReadProcessed(t, processed)
279+ drainCoordinatorTimers(clock) // detach must clear the pending output work.
280+
281+ target.mu.Lock()
282+ target.client = nil
283+ target.mu.Unlock()
284+ rc.noteDetach(targetClient)
285+ steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
286+ awaitPTYReadProcessed(t, processed)
287+
288+ fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline)
289+ wake := <-previews
290+ require.True(t, wake.urgent)
291+ require.Equal(t, 1, wake.coalesced, "completion starts a fresh headless preview batch after detach")
292+ select {
293+ case duplicate := <-previews:
294+ t.Fatalf("sync completion must publish exactly one preview wake: %#v", duplicate)
295+ default:
296+ }
297+
298+ steps <- channelPTYStep{err: io.EOF}
299+ d.sessWg.Wait()
300+ })
301+
302+ t.Run("replacement receives one complete reset frame without later PTY output", func(t *testing.T) {
303+ pty, steps, processed := newChannelPTY(t)
304+ d, target, oldClient, _ := newManualSessionWithPTYs(t, pty)
305+ clock := newCoordinatorMockClock(t, 8)
306+ d.clock = clock.clock
307+ rc := d.attachCoordinator(target, nil, oldClient, true)
308+
309+ wakes := make(chan renderWake, 2)
310+ rc.opts.wake = func(w renderWake) { wakes <- w }
311+ d.sessWg.Add(1)
312+ go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
313+ steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")}
314+ awaitPTYReadProcessed(t, processed)
315+ drainCoordinatorTimers(clock)
316+
317+ replacement := &attachedClient{output: newOutputStateStream()}
318+ rc.noteReplace(oldClient, replacement, true)
319+ target.mu.Lock()
320+ target.client = replacement
321+ target.mu.Unlock()
322+ // The replacement's initial full paint remains gated by the batch. Its
323+ // reset must survive completion and coalesce with the completion wake.
324+ rc.invalidateForAttachment(replacement, renderInvalidation{class: invalidateUrgent, reset: true, producer: "replacement first paint"})
325+ replacementTimers := drainCoordinatorTimers(clock)
326+ steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
327+ awaitPTYReadProcessed(t, processed)
328+ requireNoWake(t, wakes)
329+
330+ fireCoordinatorTimer(t, rc, replacementTimers, urgentRenderDeadline)
331+ wake := <-wakes
332+ require.True(t, wake.urgent)
333+ require.True(t, wake.reset, "the replacement's cleared batch must repaint a complete frame")
334+ require.Equal(t, 2, wake.coalesced, "completion coalesces only with the replacement's fresh reset batch")
335+ require.Same(t, replacement, wake.attachment)
336+ select {
337+ case duplicate := <-wakes:
338+ t.Fatalf("sync completion must publish exactly one replacement wake: %#v", duplicate)
339+ default:
340+ }
341+
342+ steps <- channelPTYStep{err: io.EOF}
343+ d.sessWg.Wait()
344+ })
345+
346+ t.Run("headless pane without a preview stays quiet on completion", func(t *testing.T) {
347+ pty, steps, processed := newChannelPTY(t)
348+ d, target, client, _ := newManualSessionWithPTYs(t, pty)
349+ clock := newCoordinatorMockClock(t, 8)
350+ d.clock = clock.clock
351+ rc := d.attachCoordinator(target, nil, client, true)
352+ wakes := make(chan renderWake, 1)
353+ rc.opts.wake = func(w renderWake) { wakes <- w }
354+ target.mu.Lock()
355+ target.client = nil
356+ target.mu.Unlock()
357+ rc.noteDetach(client)
358+
359+ d.sessWg.Add(1)
360+ go d.ptyReader(target, target.tabs[0], target.tabs[0].focusedPane())
361+ steps <- channelPTYStep{data: []byte("\x1b[?2026hpending")}
362+ awaitPTYReadProcessed(t, processed)
363+ drainCoordinatorTimers(clock)
364+ steps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")}
365+ awaitPTYReadProcessed(t, processed)
366+ require.Empty(t, drainCoordinatorTimers(clock))
367+ requireNoWake(t, wakes)
368+
369+ steps <- channelPTYStep{err: io.EOF}
370+ d.sessWg.Wait()
371+ })
372+}
373+
374+func TestClearNonRenderablePaneDamage(t *testing.T) {
375+ newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) {
376+ t.Helper()
377+ p, release := newBlockingPTY(t)
378+ t.Cleanup(release)
379+ d, sess, _, sends := newManualSessionWithPTYs(t, p)
380+ tb := sess.tabs[0]
381+ return d, sess, tb, tb.focusedPane(), sends
382+ }
383+
384+ t.Run("clears headless, inactive, collapsed, and hidden panes without output", func(t *testing.T) {
385+ for _, tt := range []struct {
386+ name string
387+ setup func(*Daemon, *session, *tab, *pane)
388+ }{
389+ {name: "headless", setup: func(_ *Daemon, sess *session, _ *tab, _ *pane) { sess.client = nil }},
390+ {name: "inactive tab", setup: func(_ *Daemon, sess *session, tb *tab, _ *pane) {
391+ other := newTab(nil, domain.Size{Cols: 80, Rows: 23})
392+ sess.tabs = append([]*tab{other}, sess.tabs...)
393+ sess.active = 0
394+ require.NotSame(t, other, tb)
395+ }},
396+ {name: "collapsed", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) {
397+ // The expanded leaf is deliberately absent from panes: p is the
398+ // inactive stack leaf whose content is not composited.
399+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("visible"), layout.NewLeaf(p.id)}, Expanded: "visible"}, Focus: "visible"}
400+ }},
401+ {name: "hidden floating", setup: func(_ *Daemon, _ *session, tb *tab, p *pane) {
402+ tb.floating = floatingSlot{state: floatingHidden, pane: p, generation: 1}
403+ delete(tb.panes, p.id)
404+ }},
405+ } {
406+ t.Run(tt.name, func(t *testing.T) {
407+ d, sess, tb, p, sends := newFixture(t)
408+ tt.setup(d, sess, tb, p)
409+ p.screen.Write([]byte("damage"))
410+ d.clearNonRenderablePaneDamage(sess, tb, p)
411+ require.Empty(t, p.screen.Damage())
412+ select {
413+ case frame := <-sends:
414+ t.Fatalf("non-renderable output must not compose or send: %#v", frame)
415+ default:
416+ }
417+ })
418+ }
419+ })
420+
421+ t.Run("retains active and picker-preview pane damage", func(t *testing.T) {
422+ d, sess, tb, p, _ := newFixture(t)
423+ p.screen.Write([]byte("active"))
424+ d.clearNonRenderablePaneDamage(sess, tb, p)
425+ require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition")
426+ p.screen.ClearDamage()
427+
428+ sess.client = nil
429+ viewer := &attachedClient{}
430+ viewer.initOverlays()
431+ viewer.overlays.pickerMu.Lock()
432+ viewer.overlays.pickerPreview = tb
433+ viewer.overlays.pickerMu.Unlock()
434+ d.sessions["viewer"] = &session{id: "viewer", client: viewer}
435+ p.screen.Write([]byte("preview"))
436+ d.clearNonRenderablePaneDamage(sess, tb, p)
437+ require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition")
438+ })
439+}
440+
113 func TestPTYWriteErrorIsLogged(t *testing.T) {441 func TestPTYWriteErrorIsLogged(t *testing.T) {
114 var logs bytes.Buffer442 var logs bytes.Buffer
115 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil)))443 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(&logs, nil)))
@@ -130,56 +458,6 @@ func TestPTYWriteErrorIsLogged(t *testing.T) {
130 }458 }
131 }459 }
132 460
133-func TestAltXClosesNonFinalTabScheduler(t *testing.T) {
134- p1, releasePTY1 := newBlockingPTY(t)
135- p2, releasePTY2 := newBlockingPTY(t)
136- clk := &signalClock{called: make(chan struct{})}
137- d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
138- d.clock = clk
139- defer releasePTY1()
140- defer releasePTY2()
141-
142- d.sessWg.Add(1)
143- go d.scheduler(sess, sess.tabs[1], sess.tabs[1].focusedPane())
144- sess.tabs[1].focusedPane().dirty <- struct{}{}
145- <-clk.called
146-
147- d.handleInput(sess, ac, []byte("\x1b2"))
148- d.handleInput(sess, ac, []byte("\x1b "))
149- d.handleInput(sess, ac, []byte("CLT\r"))
150-
151- select {
152- case <-waitGroupDone(&d.sessWg):
153- case <-time.After(2 * time.Second):
154- t.Fatal("scheduler for removed tab did not exit")
155- }
156- require.Equal(t, 1, sessionCount(d))
157-}
158-
159-func TestPTYEOFClosesNonFinalTabScheduler(t *testing.T) {
160- p1, releasePTY1 := newBlockingPTY(t)
161- p2, releasePTY2 := newBlockingPTY(t)
162- clk := &signalClock{called: make(chan struct{})}
163- d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
164- d.clock = clk
165- defer releasePTY1()
166- defer releasePTY2()
167-
168- d.sessWg.Add(2)
169- go d.scheduler(sess, sess.tabs[1], sess.tabs[1].focusedPane())
170- go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
171- sess.tabs[1].focusedPane().dirty <- struct{}{}
172- <-clk.called
173- releasePTY2()
174-
175- select {
176- case <-waitGroupDone(&d.sessWg):
177- case <-time.After(2 * time.Second):
178- t.Fatal("scheduler for EOF-removed tab did not exit")
179- }
180- require.Equal(t, 1, sessionCount(d))
181-}
182-
183 func TestAltXClosesFinalTabAndDetaches(t *testing.T) {461 func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
184 d, sess, ac, sends, releases := newManualTabSession(t, 1)462 d, sess, ac, sends, releases := newManualTabSession(t, 1)
185 defer releases[0]()463 defer releases[0]()
@@ -262,87 +540,6 @@ func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
262 d.sessWg.Wait()540 d.sessWg.Wait()
263 }541 }
264 542
265-func TestSchedulerDebounceCoalesces(t *testing.T) {
266- mc := portsmocks.NewMockClock(t)
267- mc.EXPECT().Now().Return(time.Now()).Maybe()
268- mt := portsmocks.NewMockTimer(t)
269- timerCh := make(chan time.Time, 1)
270- newTimerCalled := make(chan struct{}, 4)
271-
272- mc.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(time.Duration) ports.Timer {
273- select {
274- case newTimerCalled <- struct{}{}:
275- default:
276- }
277- return mt
278- }).Maybe()
279- mt.EXPECT().C().Return(timerCh).Maybe()
280- mt.EXPECT().Stop().Return(true).Maybe()
281-
282- var outputs atomic.Int32
283- gotOutput := make(chan struct{}, 1)
284- tr := portsmocks.NewMockTransport(t)
285- tr.EXPECT().Close().Return(nil).Maybe()
286- tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
287- if f.Type == ports.MsgOutput {
288- outputs.Add(1)
289- select {
290- case gotOutput <- struct{}{}:
291- default:
292- }
293- }
294- return nil
295- }).Maybe()
296-
297- d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
298- sctx, cancel := context.WithCancel(context.Background())
299- win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 5})
300- ac := &attachedClient{tr: tr, output: newOutputStateStream()}
301- ac.initOverlays()
302- sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
303- ac.setSession(sess)
304-
305- win.mu.Lock()
306- win.focusedPane().screen.Write([]byte("hi"))
307- win.mu.Unlock()
308-
309- d.sessWg.Add(1)
310- go d.scheduler(sess, win, win.focusedPane())
311-
312- // First dirty opens the debounce window.
313- win.focusedPane().dirty <- struct{}{}
314- <-newTimerCalled
315-
316- // A burst of further dirties inside the window must be absorbed.
317- for range 5 {
318- select {
319- case win.focusedPane().dirty <- struct{}{}:
320- default:
321- }
322- }
323-
324- // Fire the timer once: exactly one render.
325- timerCh <- time.Now()
326- <-gotOutput
327-
328- cancel()
329- d.sessWg.Wait()
330- require.Equal(t, int32(1), outputs.Load(), "N dirties in one tab must render exactly once")
331-}
332-
333-func TestSchedulerAdaptiveDebounceFloodAndIdle(t *testing.T) {
334- delay := nextDebounceDelay(minDebounceInterval, 3)
335- require.Greater(t, delay, minDebounceInterval, "sustained flood should widen debounce")
336- delay = nextDebounceDelay(delay, 3)
337- require.Greater(t, delay, minDebounceInterval+debounceStep, "continued flood should keep adapting")
338-}
339-
340-func TestSchedulerAdaptiveDebounceResetsAfterQuietPeriod(t *testing.T) {
341- delay := nextDebounceDelay(minDebounceInterval, 2)
342- require.Greater(t, delay, minDebounceInterval)
343- require.Equal(t, minDebounceInterval, nextDebounceDelay(delay, 0), "isolated update after quiet window should restore idle latency")
344-}
345-
346 // --- resize ordering --------------------------------------------------------543 // --- resize ordering --------------------------------------------------------
347 544
348 func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {545 func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
@@ -451,39 +648,6 @@ func TestTranslatePaneDamagePreservesFullWidthScrollFastPathOnly(t *testing.T) {
451 }648 }
452 }649 }
453 650
454-func TestRenderClearsDamageOnEmittingPaneWhenDetached(t *testing.T) {
455- tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
456- focused := tb.focusedPane()
457- emitting := newPane("pane-2", nil, domain.Size{Cols: 10, Rows: 3})
458- tb.panes[emitting.id] = emitting
459- tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(focused.id), layout.NewLeaf(emitting.id)}}
460- tb.tree.Focus = focused.id
461- sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0}
462- d := newTestDaemon(t, nil, stubClock{})
463-
464- focused.screen.Write([]byte("a"))
465- emitting.screen.Write([]byte("b"))
466- d.render(sess, tb, emitting)
467-
468- require.NotEmpty(t, focused.screen.Damage(), "focused pane damage should be untouched by detached render for another pane")
469- require.Empty(t, emitting.screen.Damage(), "emitting pane damage should be drained")
470-}
471-
472-func TestRenderWithNilTransportDoesNotAdvanceStateCounter(t *testing.T) {
473- tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
474- sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0}
475- ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 10, Rows: 4}}
476- ac.initOverlays()
477- sess.client = ac
478- ac.setSession(sess)
479- d := newTestDaemon(t, nil, stubClock{})
480-
481- tb.focusedPane().screen.Write([]byte("x"))
482- d.render(sess, tb, tb.focusedPane())
483-
484- require.Zero(t, ac.output.next)
485-}
486-
487 func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {651 func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {
488 win := newTab(nil, domain.Size{Cols: 41, Rows: 4})652 win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
489 left := win.focusedPane()653 left := win.focusedPane()
@@ -977,75 +1141,6 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
977 d.waitNotifies()1141 d.waitNotifies()
978 }1142 }
979 1143
980-func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) {
981- mc := portsmocks.NewMockClock(t)
982- mc.EXPECT().Now().Return(time.Now()).Maybe()
983- mt := portsmocks.NewMockTimer(t)
984- timerCh := make(chan time.Time, 1)
985- mc.EXPECT().NewTimer(mock.Anything).Return(mt).Maybe()
986- mt.EXPECT().C().Return(timerCh).Maybe()
987- mt.EXPECT().Stop().Return(true).Maybe()
988-
989- var outputs atomic.Int32
990- gotOutput := make(chan struct{}, 1)
991- tr := portsmocks.NewMockTransport(t)
992- tr.EXPECT().Close().Return(nil).Maybe()
993- tr.EXPECT().Send(mock.Anything).RunAndReturn(func(f ports.Frame) error {
994- if f.Type == ports.MsgOutput {
995- outputs.Add(1)
996- select {
997- case gotOutput <- struct{}{}:
998- default:
999- }
1000- }
1001- return nil
1002- }).Maybe()
1003-
1004- d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
1005- sctx, cancel := context.WithCancel(context.Background())
1006- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
1007- win.ctx, win.cancel = sctx, cancel
1008- p := win.focusedPane()
1009- p.ctx, p.cancel = sctx, cancel
1010- ac := &attachedClient{tr: tr, output: newOutputStateStream()}
1011- ac.initOverlays()
1012- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1013- ac.setSession(sess)
1014-
1015- p.mu.Lock()
1016- p.screen.Write([]byte("before"))
1017- p.mu.Unlock()
1018-
1019- d.sessWg.Add(1)
1020- go d.scheduler(sess, win, p)
1021- p.dirty <- struct{}{}
1022-
1023- p.mu.Lock()
1024- p.screen.Write([]byte("\x1b[?2026hafter"))
1025- p.mu.Unlock()
1026- timerCh <- time.Now()
1027-
1028- select {
1029- case <-gotOutput:
1030- t.Fatal("scheduler rendered while synchronized update was active")
1031- case <-time.After(20 * time.Millisecond):
1032- }
1033- require.Equal(t, int32(0), outputs.Load())
1034-
1035- p.mu.Lock()
1036- p.screen.Write([]byte(" done\x1b[?2026l"))
1037- p.mu.Unlock()
1038- p.flush <- struct{}{}
1039- select {
1040- case <-gotOutput:
1041- case <-time.After(time.Second):
1042- t.Fatal("sync end did not flush deferred damage")
1043- }
1044-
1045- cancel()
1046- d.sessWg.Wait()
1047-}
1048-
1049 func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {1144 func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
1050 d := newTestDaemon(t, nil, stubClock{})1145 d := newTestDaemon(t, nil, stubClock{})
1051 p := portsmocks.NewMockPTY(t)1146 p := portsmocks.NewMockPTY(t)
@@ -1089,166 +1184,6 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
1089 }1184 }
1090 }1185 }
1091 1186
1092-func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testing.T) {
1093- d := newTestDaemon(t, nil, stubClock{})
1094- p := portsmocks.NewMockPTY(t)
1095- chunks := [][]byte{[]byte("\x1b[?2026hhello\x1b[?2026l")}
1096- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
1097- if len(chunks) == 0 {
1098- return 0, io.EOF
1099- }
1100- n := copy(buf, chunks[0])
1101- chunks = chunks[1:]
1102- return n, nil
1103- })
1104- p.EXPECT().Close().Return(nil).Maybe()
1105-
1106- sctx, cancel := context.WithCancel(context.Background())
1107- win := newTestTabWithContext(p, sctx, cancel)
1108- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
1109- d.sessions[sess.id] = sess
1110- d.sessWg.Add(1)
1111- d.ptyReader(sess, win, win.focusedPane())
1112-
1113- select {
1114- case <-win.focusedPane().dirty:
1115- t.Fatal("same-read synchronized update end should request flush, not dirty")
1116- default:
1117- }
1118- select {
1119- case <-win.focusedPane().flush:
1120- case <-time.After(time.Second):
1121- t.Fatal("same-read synchronized update end did not request immediate flush")
1122- }
1123-}
1124-
1125-func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing.T) {
1126- d := newTestDaemon(t, nil, stubClock{})
1127- p := portsmocks.NewMockPTY(t)
1128- chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")}
1129- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
1130- if len(chunks) == 0 {
1131- return 0, io.EOF
1132- }
1133- n := copy(buf, chunks[0])
1134- chunks = chunks[1:]
1135- return n, nil
1136- })
1137- p.EXPECT().Close().Return(nil).Maybe()
1138-
1139- sctx, cancel := context.WithCancel(context.Background())
1140- win := newTestTabWithContext(p, sctx, cancel)
1141- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
1142- d.sessions[sess.id] = sess
1143- d.sessWg.Add(1)
1144- d.ptyReader(sess, win, win.focusedPane())
1145-
1146- select {
1147- case <-win.focusedPane().dirty:
1148- t.Fatal("dirty signaled while synchronized update was active")
1149- default:
1150- }
1151- select {
1152- case <-win.focusedPane().flush:
1153- case <-time.After(time.Second):
1154- t.Fatal("sync end did not request immediate flush")
1155- }
1156-}
1157-
1158-func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) {
1159- clk := &signalClock{timers: make(chan *signalTimer, 1)}
1160- d := newTestDaemon(t, nil, clk)
1161- p := portsmocks.NewMockPTY(t)
1162- release := make(chan struct{})
1163- read := 0
1164- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
1165- if read == 0 {
1166- read++
1167- return copy(buf, []byte("\x1b[?2026habandoned")), nil
1168- }
1169- <-release
1170- return 0, io.EOF
1171- })
1172- p.EXPECT().Close().Return(nil).Maybe()
1173-
1174- tr, sends := newCapturingTransport(t)
1175- sctx, cancel := context.WithCancel(context.Background())
1176- win := newTestTabWithContext(p, sctx, cancel)
1177- ac := &attachedClient{tr: tr, output: newOutputStateStream()}
1178- ac.initOverlays()
1179- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1180- ac.setSession(sess)
1181- d.sessions[sess.id] = sess
1182- d.sessWg.Add(2)
1183- go d.scheduler(sess, win, win.focusedPane())
1184- go d.ptyReader(sess, win, win.focusedPane())
1185-
1186- timer := <-clk.timers
1187- timer.ch <- time.Now()
1188- awaitFrame(t, sends, ports.MsgOutput)
1189-
1190- win.mu.Lock()
1191- active := win.focusedPane().screen.SyncUpdateActive()
1192- got := screenLineText(win.focusedPane().screen, 0)
1193- win.mu.Unlock()
1194- require.False(t, active)
1195- require.Contains(t, got, "abandoned")
1196-
1197- close(release)
1198- cancel()
1199- d.sessWg.Wait()
1200-}
1201-
1202-func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
1203- clk := &signalClock{timers: make(chan *signalTimer, 1)}
1204- d := newTestDaemon(t, nil, clk)
1205- p := portsmocks.NewMockPTY(t)
1206- release := make(chan struct{})
1207- chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")}
1208- p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
1209- if len(chunks) > 0 {
1210- n := copy(buf, chunks[0])
1211- chunks = chunks[1:]
1212- return n, nil
1213- }
1214- <-release
1215- return 0, io.EOF
1216- })
1217- p.EXPECT().Close().Return(nil).Maybe()
1218-
1219- tr, sends := newCapturingTransport(t)
1220- sctx, cancel := context.WithCancel(context.Background())
1221- win := newTestTabWithContext(p, sctx, cancel)
1222- ac := &attachedClient{tr: tr, output: newOutputStateStream()}
1223- ac.initOverlays()
1224- sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1225- ac.setSession(sess)
1226- d.sessions[sess.id] = sess
1227- d.sessWg.Add(2)
1228- go d.scheduler(sess, win, win.focusedPane())
1229- go d.ptyReader(sess, win, win.focusedPane())
1230-
1231- timer := <-clk.timers
1232- awaitFrame(t, sends, ports.MsgOutput)
1233- timer.ch <- time.Now()
1234-
1235- select {
1236- case f := <-sends:
1237- if f.Type == ports.MsgOutput {
1238- t.Fatal("stale synchronized update watchdog produced an extra render")
1239- }
1240- case <-time.After(50 * time.Millisecond):
1241- }
1242- win.mu.Lock()
1243- active := win.focusedPane().screen.SyncUpdateActive()
1244- win.mu.Unlock()
1245- require.False(t, active)
1246-
1247- close(release)
1248- cancel()
1249- d.sessWg.Wait()
1250-}
1251-
1252 func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {1187 func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
1253 p, _ := newBlockingPTY(t)1188 p, _ := newBlockingPTY(t)
1254 d, sess, ac, sends := newManualSessionWithPTYs(t, p)1189 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
M internal/usecase/daemon/resume.go +4 -4
@@ -48,11 +48,10 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
48 if !d.prepareParkAttachment(sess, ac) {48 if !d.prepareParkAttachment(sess, ac) {
49 return false49 return false
50 }50 }
51- // A parked attachment has no valid visible destination. Retire both the51+ // A parked attachment has no valid visible destination. Cancel its
52- // deferred diff and any resize-idle callback before registering its grace52+ // resize-idle callback before registering the grace timer; resume performs
53- // timer; resume performs a fresh full paint.53+ // a fresh full paint.
54 ac.cancelResizePaint()54 ac.cancelResizePaint()
55- ac.clearPaintDeferred()
56 d.mu.Lock()55 d.mu.Lock()
57 if d.closing {56 if d.closing {
58 d.mu.Unlock()57 d.mu.Unlock()
@@ -188,6 +187,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
188 sess.client = ac187 sess.client = ac
189 sess.terminal = terminalEnv{TrueColor: h.TrueColor}188 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
190 sess.mu.Unlock()189 sess.mu.Unlock()
190+ d.attachCoordinator(sess, nil, ac, false)
191 d.touchMRU(sess)191 d.touchMRU(sess)
192 d.log.Info("client resumed", "session", sess.name)192 d.log.Info("client resumed", "session", sess.name)
193 return sess, ac, true, nil193 return sess, ac, true, nil
M internal/usecase/daemon/resume_test.go +49 -0
@@ -77,6 +77,54 @@ func TestNamedLinkLossParks(t *testing.T) {
77 require.True(t, parked, "named resume-capable link loss is parked")77 require.True(t, parked, "named resume-capable link loss is parked")
78 }78 }
79 79
80+func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {
81+ pty, releasePTY := newBlockingPTY(t)
82+ defer releasePTY()
83+ clock := newCoordinatorMockClock(t, 4)
84+ d := newTestDaemon(t, newFactorySeq(t, pty), clock.clock)
85+ oldTransport := &closeTrackingTransport{}
86+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "resume-welcome-gate", 0), oldTransport)
87+ require.NoError(t, err)
88+ token := ac.resumeToken
89+ d.clientGone(sess, ac, oldTransport, false)
90+
91+ tr := newWelcomeBlockingTransport(t)
92+ done := make(chan struct{})
93+ resumeHello := helloResumeCapable(ports.IntentResume, sess.name, token)
94+ go func() {
95+ d.handleHello(tr.tr, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(resumeHello)})
96+ close(done)
97+ }()
98+
99+ <-tr.welcomeEntered
100+ welcome := <-tr.sends
101+ require.Equal(t, ports.MsgWelcome, welcome.Type)
102+ sess.mu.Lock()
103+ resumed := sess.client
104+ sess.mu.Unlock()
105+ require.Same(t, ac, resumed)
106+
107+ d.invalidateRender(sess, resumed, true, "resume-welcome-gate-test")
108+ timer := awaitLatestCoordinatorTimer(t, clock)
109+ rc := sess.renderCoordinator()
110+ rc.mu.Lock()
111+ workerDone := rc.normalWorkerDone
112+ rc.mu.Unlock()
113+ require.NotNil(t, workerDone)
114+ timer.ch <- time.Time{}
115+ awaitHandshakeWorker(t, workerDone)
116+ requireNoCoordinatorOutputFrame(t, tr.sends)
117+
118+ close(tr.releaseWelcome)
119+ output := awaitFrame(t, tr.sends, ports.MsgOutput)
120+ first, err := ports.UnmarshalOutput(output.Payload)
121+ require.NoError(t, err)
122+ require.Zero(t, first.BaseStateNum)
123+ tr.finish()
124+ <-done
125+ requireNoCoordinatorOutputFrame(t, tr.sends)
126+}
127+
80 func TestEphemeralLinkLossParksAndResumes(t *testing.T) {128 func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
81 pty, release := newBlockingPTY(t)129 pty, release := newBlockingPTY(t)
82 defer release()130 defer release()
@@ -228,6 +276,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
228 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)276 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr)
229 require.NoError(t, err)277 require.NoError(t, err)
230 require.Same(t, ac, resumedAC)278 require.Same(t, ac, resumedAC)
279+ require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedAC))
231 d.firstPaint(resumedSess, resumedAC, resumedAC.size)280 d.firstPaint(resumedSess, resumedAC, resumedAC.size)
232 281
233 sends := newTr.Sends()282 sends := newTr.Sends()
A internal/usecase/daemon/resume_wake_incarnation_test.go +68 -0
@@ -0,0 +1,68 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// A wake can have passed coordinator scheduling before a link loss parks its
13+// attachment. Resume deliberately reuses that attachment object, so pointer
14+// identity alone must not let the old wake paint the new transport before its
15+// Welcome frame.
16+func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
17+ pty, releasePTY := newBlockingPTY(t)
18+ defer releasePTY()
19+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
20+
21+ oldTransport := &closeTrackingTransport{}
22+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
23+ require.NoError(t, err)
24+ token := ac.resumeToken
25+ rc := sess.renderCoordinator()
26+ require.NotNil(t, rc)
27+ require.True(t, rc.markAttachmentReady(ac))
28+
29+ // Pause after dispatch but before the coordinator's normal wake callback
30+ // reaches paint/sendMu. This is the precise stale-wake window.
31+ originalWake := rc.opts.wake
32+ wakeDispatched := make(chan struct{})
33+ releaseWake := make(chan struct{})
34+ rc.opts.wake = func(w renderWake) {
35+ close(wakeDispatched)
36+ <-releaseWake
37+ originalWake(w)
38+ }
39+ wakeDone := make(chan struct{})
40+ go func() {
41+ d.invalidateRenderNow(sess, ac, true, "resume-wake-test")
42+ close(wakeDone)
43+ }()
44+ <-wakeDispatched
45+
46+ d.clientGone(sess, ac, oldTransport, false)
47+ newTransport := &closeTrackingTransport{}
48+ resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, sess.name, token), newTransport, domain.Size{Cols: 80, Rows: 24})
49+ require.NoError(t, err)
50+ require.True(t, ok)
51+ require.Same(t, sess, resumedSess)
52+ require.Same(t, ac, resumedAC)
53+
54+ close(releaseWake)
55+ <-wakeDone
56+ require.Empty(t, newTransport.Sends(), "a stale pre-resume wake must not emit Output before Welcome")
57+ ac.sendMu.Lock()
58+ staleWakeComposed := ac.composed.valid
59+ ac.sendMu.Unlock()
60+ require.False(t, staleWakeComposed, "a stale wake must not mutate the resumed attachment shadow")
61+
62+ // The new attachment incarnation accepts the required paint only after its
63+ // replacement transport has completed Welcome.
64+ rc.opts.wake = originalWake
65+ require.True(t, rc.markAttachmentReady(ac))
66+ d.firstPaint(sess, ac, ac.size)
67+ require.NotEmpty(t, newTransport.Sends())
68+}
M internal/usecase/daemon/session.go +26 -34
@@ -1,27 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the1+// Package daemon holds vev's server-side session multiplexer use case.
2-// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.
4-//
5-// Concurrency model (sessions own one or more PTY-backed tabs):
6-//
7-// - Serve runs the accept loop. Each accepted connection is handled by its
8-// own goroutine (handleConn): it reads the first frame and routes it to a
9-// session create/attach, a list, or a kill.
10-// - Per session there are exactly two long-lived goroutines: the PTY reader
11-// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12-// and the render scheduler (debounces dirties and paints the attached
13-// client). Both are tied to the session context and unwind when the
14-// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15-// scheduler).
16-// - The daemon exits (Serve returns) when the last session is removed, or
17-// when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).
19-//
20-// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25 package daemon2 package daemon
26 3
27 import (4 import (
@@ -67,6 +44,11 @@ type session struct {
67 mruAt atomic.Uint6444 mruAt atomic.Uint64
68 snapDirty atomic.Bool45 snapDirty atomic.Bool
69 snapEligible atomic.Bool46 snapEligible atomic.Bool
47+ // syncGen makes synchronized-output watchdog generations unique across all
48+ // panes in this session.
49+ syncGen atomic.Uint64
50+ // coordinator fans in this session's producer render invalidations.
51+ coordinator atomic.Pointer[renderCoordinator]
70 // clipFiles records clipboard-image-transfer temp file paths (see52 // clipFiles records clipboard-image-transfer temp file paths (see
71 // clipboard.go) written for this session, removed best-effort in53 // clipboard.go) written for this session, removed best-effort in
72 // killSession.54 // killSession.
@@ -75,7 +57,7 @@ type session struct {
75 57
76 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.58 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
77 type tab struct {59 type tab struct {
78- mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, previewClient, and pane map membership60+ mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership
79 61
80 stableID string62 stableID string
81 name string63 name string
@@ -88,9 +70,6 @@ type tab struct {
88 // floating is independent from the normal layout tree and pane map.70 // floating is independent from the normal layout tree and pane map.
89 floating floatingSlot71 floating floatingSlot
90 72
91- // previewClient tracks the one client currently previewing this tab in the picker.
92- // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
93- previewClient *attachedClient
94 // attention fields are guarded by the owning session.mu.73 // attention fields are guarded by the owning session.mu.
95 attention bool74 attention bool
96 attentionAt time.Time75 attentionAt time.Time
@@ -100,7 +79,7 @@ type tab struct {
100 // attachedClient is a client currently attached to a session's tab. rend is79 // attachedClient is a client currently attached to a session's tab. rend is
101 // its private renderer shadow (so each client gets output minimised against80 // its private renderer shadow (so each client gets output minimised against
102 // what it has actually seen). sendMu serialises the two senders — the render81 // what it has actually seen). sendMu serialises the two senders — the render
103-// scheduler and the connection handler — so the transport's single-writer82+// coordinator and the connection handler — so the transport's single-writer
104 83
105 func (d *Daemon) touchMRU(sess *session) {84 func (d *Daemon) touchMRU(sess *session) {
106 if d == nil || sess == nil {85 if d == nil || sess == nil {
@@ -258,16 +237,18 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
258 ac.setSession(nil)237 ac.setSession(nil)
259 from.mu.Unlock()238 from.mu.Unlock()
260 239
240+ // Prepare source invalidation, output rebase, and destination coordinator
241+ // identity before publishing the destination attachment.
242+ d.handoffCoordinator(from, newSess, nil, ac)
243+ ac.setSession(newSess)
261 newSess.mu.Lock()244 newSess.mu.Lock()
262 newSess.client = ac245 newSess.client = ac
263 newSess.mu.Unlock()246 newSess.mu.Unlock()
264 247
265 d.touchMRU(newSess)248 d.touchMRU(newSess)
266- ac.setSession(newSess)
267 ac.recordPreviousSession(from)249 ac.recordPreviousSession(from)
268 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)250 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
269 d.mu.Unlock()251 d.mu.Unlock()
270-
271 d.firstPaint(newSess, ac, sz)252 d.firstPaint(newSess, ac, sz)
272 return nil253 return nil
273 }254 }
@@ -418,9 +399,9 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
418 sess.mu.Unlock()399 sess.mu.Unlock()
419 d.log.Info("pane created", "session", name, "pane", p.id)400 d.log.Info("pane created", "session", name, "pane", p.id)
420 }401 }
421- d.sessWg.Add(2)402+ // Scheduler ownership was removed; this launch creates exactly one reader.
403+ d.sessWg.Add(1)
422 go d.ptyReader(sess, tb, p)404 go d.ptyReader(sess, tb, p)
423- go d.scheduler(sess, tb, p)
424 }405 }
425 406
426 // attachClient makes ac the session's current client, displacing any prior one407 // attachClient makes ac the session's current client, displacing any prior one
@@ -641,12 +622,20 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
641 tb.cancel()622 tb.cancel()
642 }623 }
643 d.teardownFloating(tb, ac)624 d.teardownFloating(tb, ac)
625+ if rc := sess.renderCoordinator(); rc != nil {
626+ tb.mu.Lock()
627+ panes := tb.panesSnapshot()
628+ tb.mu.Unlock()
629+ for _, p := range panes {
630+ rc.noteSyncPaneRemoved(p)
631+ }
632+ }
644 tb.closeAllPanes()633 tb.closeAllPanes()
645 if wasActive {634 if wasActive {
646 d.activateTab(sess, destination)635 d.activateTab(sess, destination)
647 }636 }
648 if repaint && ac != nil {637 if repaint && ac != nil {
649- d.paint(sess, ac, true)638+ d.invalidateRender(sess, ac, true, "session.go")
650 }639 }
651 if ringing {640 if ringing {
652 d.repaintAllAttachedClients()641 d.repaintAllAttachedClients()
@@ -731,6 +720,9 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
731 ac := sess.client720 ac := sess.client
732 sess.client = nil721 sess.client = nil
733 sess.mu.Unlock()722 sess.mu.Unlock()
723+ if rc := sess.renderCoordinator(); rc != nil {
724+ rc.noteSessionTeardown()
725+ }
734 if ac != nil {726 if ac != nil {
735 ac.cancelResizePaint()727 ac.cancelResizePaint()
736 d.unregisterPreview(ac)728 d.unregisterPreview(ac)
M internal/usecase/daemon/session_back.go +1 -1
@@ -11,7 +11,7 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
11 target := ac.previousSession.Get()11 target := ac.previousSession.Get()
12 if target == nil || target == current || d.sessionByID(target.id) != target {12 if target == nil || target == current || d.sessionByID(target.id) != target {
13 ac.clearPreviousSessionIf(target)13 ac.clearPreviousSessionIf(target)
14- d.paint(current, ac, true)14+ d.invalidateRender(current, ac, true, "session_back.go")
15 return15 return
16 }16 }
17 d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1})17 d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1})
M internal/usecase/daemon/session_test.go +21 -0
@@ -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()
M internal/usecase/daemon/snapshot_test.go +23 -0
@@ -213,6 +213,29 @@ func TestPTYReaderSynchronizedUpdateFlushMarksSnapshotDirty(t *testing.T) {
213 require.True(t, sess.snapDirty.Load())213 require.True(t, sess.snapDirty.Load())
214 }214 }
215 215
216+func TestPTYReaderSameReadSynchronizedOutputUsesUrgentCoordinatorDeadline(t *testing.T) {
217+ d := newTestDaemon(t, nil, stubClock{})
218+ p := chunkReadPTY(t, []byte("\x1b[?2026hcomplete batch\x1b[?2026l"))
219+ sctx, cancel := context.WithCancel(context.Background())
220+ defer cancel()
221+ tb := newTestTabWithContext(p, sctx, cancel)
222+ sess := &session{id: "sync-urgent", name: "sync-urgent", tabs: []*tab{tb}, client: &attachedClient{}, ctx: sctx, cancel: cancel}
223+ invs := make(chan renderInvalidation, 1)
224+ clock := newCoordinatorMockClock(t, 2)
225+ sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{
226+ clock: clock.clock,
227+ onInvalidate: func(inv renderInvalidation) { invs <- inv },
228+ }))
229+ d.sessions[sess.id] = sess
230+ d.sessWg.Add(1)
231+ d.ptyReader(sess, tb, tb.focusedPane())
232+
233+ inv := awaitInvalidation(t, invs)
234+ require.Equal(t, invalidateUrgent, inv.class, "a complete same-read synchronized batch must flush urgently")
235+ timer := awaitCoordinatorScheduledTimer(t, clock)
236+ require.Equal(t, urgentRenderDeadline, timer.duration)
237+}
238+
216 func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) {239 func TestSnapshotSaverKeepsDirtyWhenWriteFails(t *testing.T) {
217 clock := newManualSnapshotClock()240 clock := newManualSnapshotClock()
218 store := portsmocks.NewMockSnapshotStore(t)241 store := portsmocks.NewMockSnapshotStore(t)
M internal/usecase/daemon/status.go +1 -24
@@ -1,27 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the1+// Package daemon holds vev's server-side session multiplexer use case.
2-// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.
4-//
5-// Concurrency model (sessions own one or more PTY-backed tabs):
6-//
7-// - Serve runs the accept loop. Each accepted connection is handled by its
8-// own goroutine (handleConn): it reads the first frame and routes it to a
9-// session create/attach, a list, or a kill.
10-// - Per session there are exactly two long-lived goroutines: the PTY reader
11-// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12-// and the render scheduler (debounces dirties and paints the attached
13-// client). Both are tied to the session context and unwind when the
14-// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15-// scheduler).
16-// - The daemon exits (Serve returns) when the last session is removed, or
17-// when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).
19-//
20-// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25 package daemon2 package daemon
26 3
27 import (4 import (
M internal/usecase/daemon/status_test.go +29 -13
@@ -1309,11 +1309,11 @@ func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
1309 }1309 }
1310 }1310 }
1311 1311
1312-func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {1312+func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
1313 p1, releasePTY1 := newBlockingPTY(t)1313 p1, releasePTY1 := newBlockingPTY(t)
1314 p2, releasePTY2 := newBlockingPTY(t)1314 p2, releasePTY2 := newBlockingPTY(t)
1315- clock := &signalClock{timers: make(chan *signalTimer, 1)}1315+ clock := newCoordinatorMockClock(t, 64)
1316- d := newTestDaemon(t, newFactorySeq(t, p1, p2), clock)1316+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), clock.clock)
1317 tr, sends, releaseConn := newConn(t,1317 tr, sends, releaseConn := newConn(t,
1318 mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),1318 mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),
1319 frameInput([]byte("\x1b ")),1319 frameInput([]byte("\x1b ")),
@@ -1326,16 +1326,32 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
1326 hg.Go(func() { d.handleConn(tr) })1326 hg.Go(func() { d.handleConn(tr) })
1327 awaitFrame(t, sends, ports.MsgWelcome)1327 awaitFrame(t, sends, ports.MsgWelcome)
1328 first := awaitFrame(t, sends, ports.MsgOutput)1328 first := awaitFrame(t, sends, ports.MsgOutput)
1329- palette := awaitFrame(t, sends, ports.MsgOutput)1329+ require.Eventually(t, func() bool {
1330- created := awaitFrame(t, sends, ports.MsgOutput)1330+ sessions := listSessions(t, d)
1331- switched := awaitFrame(t, sends, ports.MsgOutput)1331+ return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 2
1332- // The resize frame is intentionally idle-coalesced; fire its fake-clock1332+ }, 2*time.Second, time.Millisecond)
1333- // deadline rather than relying on the old synchronous behavior.1333+
1334- (<-clock.timers).ch <- time.Time{}1334+ // The queued create, switch, and resize transitions may collapse into one
1335- resized := awaitFrame(t, sends, ports.MsgOutput)1335+ // latest-state wake. Advance every retained PR #71/coordinator deadline
1336-1336+ // until that coalesced output is observable.
1337- _ = palette1337+ var resized ports.Frame
1338- for _, f := range []ports.Frame{first, created, switched, resized} {1338+ deadline := time.NewTimer(2 * time.Second)
1339+ defer deadline.Stop()
1340+ for resized.Type != ports.MsgOutput {
1341+ select {
1342+ case f := <-sends:
1343+ if f.Type != ports.MsgOutput {
1344+ t.Fatalf("unexpected frame type %d while awaiting resized output", f.Type)
1345+ }
1346+ resized = f
1347+ case timer := <-clock.timers:
1348+ timer.ch <- time.Time{}
1349+ case <-deadline.C:
1350+ t.Fatal("timed out awaiting resized output frame")
1351+ }
1352+ }
1353+
1354+ for _, f := range []ports.Frame{first, resized} {
1339 out, err := ports.UnmarshalOutput(f.Payload)1355 out, err := ports.UnmarshalOutput(f.Payload)
1340 require.NoError(t, err)1356 require.NoError(t, err)
1341 require.Contains(t, string(out.Data), "work")1357 require.Contains(t, string(out.Data), "work")
M internal/usecase/daemon/wire.go +1 -24
@@ -1,27 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the1+// Package daemon holds vev's server-side session multiplexer use case.
2-// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3-// and VT screen, and the per-client debounced render scheduler.
4-//
5-// Concurrency model (sessions own one or more PTY-backed tabs):
6-//
7-// - Serve runs the accept loop. Each accepted connection is handled by its
8-// own goroutine (handleConn): it reads the first frame and routes it to a
9-// session create/attach, a list, or a kill.
10-// - Per session there are exactly two long-lived goroutines: the PTY reader
11-// (drains child output into the VT screen and pokes a cap-1 dirty channel)
12-// and the render scheduler (debounces dirties and paints the attached
13-// client). Both are tied to the session context and unwind when the
14-// session is killed (pty.Close unblocks the reader; ctx cancel stops the
15-// scheduler).
16-// - The daemon exits (Serve returns) when the last session is removed, or
17-// when the parent context is cancelled (graceful shutdown notifies any
18-// attached clients with ReasonServerShutdown).
19-//
20-// Locking: a pane's screen/scrollback and per-client renderer shadow are
21-// guarded by pane.mu/tab.mu as appropriate; the attached-client pointer by
22-// session.mu; the registry by Daemon.mu. When more than one is held the order
23-// is always attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
24-// The PTY reader only ever takes pane.mu, so it never blocks on a slow client.
25 package daemon2 package daemon
26 3
27 import (4 import (

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