detached 37d1a50

refactor(daemon): remove legacy render ownership

commit
37d1a50fc98947d2ec53cb3081a851fe0b1b548d
author
brice · · 2026-07-11 15:20 UTC
parent
0e2e6e9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +88 -1270
M internal/usecase/daemon/bar_refresh.go +5 -7
@@ -306,12 +306,10 @@ func (d *Daemon) pokeSessionRender(sess *session) {
306306 if sess == nil {
307307 return
308308 }
309- if tb := sess.activeTab(); tb != nil {
310- tb.mu.Lock()
311- p := tb.focusedPane()
312- tb.mu.Unlock()
313- if p != nil {
314- signal(p.dirty)
315- }
309+ sess.mu.Lock()
310+ ac := sess.client
311+ sess.mu.Unlock()
312+ if ac != nil {
313+ d.invalidateRender(sess, ac, false, "bar_refresh.go")
316314 }
317315 }
@@ -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 +24 -47
@@ -1,27 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the
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.
1+// Package daemon holds vev's server-side session multiplexer use case.
252 package daemon
263
274 import (
@@ -197,23 +174,6 @@ func (ac *attachedClient) ackOutputState(state uint64) {
197174 ac.output.ack(state)
198175 }
199176
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-
217177 // send serialises a frame onto the client's transport.
218178 func (ac *attachedClient) send(f ports.Frame) error {
219179 _, err := ac.sendTransport(f)
@@ -401,10 +361,27 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
401361 rc := sess.renderCoordinator()
402362 if rc == nil {
403363 rc = newRenderCoordinator(renderCoordinatorOptions{
404- clock: d.clock,
405- wake: func(w renderWake) { d.paint(sess, ac, w.reset) },
406- ackReady: func() bool { return true },
407- syncActive: func() bool { return false },
364+ clock: d.clock,
365+ wake: func(w renderWake) {
366+ sess.mu.Lock()
367+ current := sess.client
368+ sess.mu.Unlock()
369+ if current != nil {
370+ d.paint(sess, current, w.reset)
371+ }
372+ },
373+ ackReady: func() bool {
374+ sess.mu.Lock()
375+ current := sess.client
376+ sess.mu.Unlock()
377+ if current == nil {
378+ return false
379+ }
380+ current.sendMu.Lock()
381+ ready := !current.output.atCapacity()
382+ current.sendMu.Unlock()
383+ return ready
384+ },
408385 })
409386 sess.installRenderCoordinator(rc)
410387 }
@@ -519,8 +496,8 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
519496 case ports.MsgAck:
520497 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
521498 ac.ackOutputState(ack.AckedStateNum)
522- if reset, ok := ac.takeDeferredPaint(); ok {
523- d.invalidateRender(sess, ac, reset, "client.go")
499+ if rc := sess.renderCoordinator(); rc != nil {
500+ rc.notifyAck()
524501 }
525502 }
526503 case ports.MsgPing:
@@ -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 (
@@ -197,23 +174,6 @@ func (ac *attachedClient) ackOutputState(state uint64) {
197 ac.output.ack(state)174 ac.output.ack(state)
198 }175 }
199 176
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.177 // send serialises a frame onto the client's transport.
218 func (ac *attachedClient) send(f ports.Frame) error {178 func (ac *attachedClient) send(f ports.Frame) error {
219 _, err := ac.sendTransport(f)179 _, err := ac.sendTransport(f)
@@ -401,10 +361,27 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
401 rc := sess.renderCoordinator()361 rc := sess.renderCoordinator()
402 if rc == nil {362 if rc == nil {
403 rc = newRenderCoordinator(renderCoordinatorOptions{363 rc = newRenderCoordinator(renderCoordinatorOptions{
404- clock: d.clock,364+ clock: d.clock,
405- wake: func(w renderWake) { d.paint(sess, ac, w.reset) },365+ wake: func(w renderWake) {
406- ackReady: func() bool { return true },366+ sess.mu.Lock()
407- syncActive: func() bool { return false },367+ current := sess.client
368+ sess.mu.Unlock()
369+ if current != nil {
370+ d.paint(sess, current, w.reset)
371+ }
372+ },
373+ ackReady: func() bool {
374+ sess.mu.Lock()
375+ current := sess.client
376+ sess.mu.Unlock()
377+ if current == nil {
378+ return false
379+ }
380+ current.sendMu.Lock()
381+ ready := !current.output.atCapacity()
382+ current.sendMu.Unlock()
383+ return ready
384+ },
408 })385 })
409 sess.installRenderCoordinator(rc)386 sess.installRenderCoordinator(rc)
410 }387 }
@@ -519,8 +496,8 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
519 case ports.MsgAck:496 case ports.MsgAck:
520 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {497 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
521 ac.ackOutputState(ack.AckedStateNum)498 ac.ackOutputState(ack.AckedStateNum)
522- if reset, ok := ac.takeDeferredPaint(); ok {499+ if rc := sess.renderCoordinator(); rc != nil {
523- d.invalidateRender(sess, ac, reset, "client.go")500+ rc.notifyAck()
524 }501 }
525 }502 }
526 case ports.MsgPing:503 case ports.MsgPing:
M internal/usecase/daemon/clipboard.go +1 -1
@@ -1,6 +1,6 @@
11 // Package daemon holds vev's server-side session multiplexer use case: the
22 // 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.
44 package daemon
55
66 import (
@@ -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 +1 -24
@@ -1,27 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the
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.
1+// Package daemon holds vev's server-side session multiplexer use case.
252 package daemon
263
274 import (
@@ -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/daemon.go +3 -29
@@ -1,29 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the
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.
1+// Package daemon holds vev's server-side session multiplexer use case.
272 package daemon
283
294 import (
@@ -48,7 +23,6 @@ import (
4823 const (
4924 minDebounceInterval = 2 * time.Millisecond
5025 maxDebounceInterval = 16 * time.Millisecond
51- debounceStep = 2 * time.Millisecond
5226 maxSyncUpdateDuration = 500 * time.Millisecond
5327 )
5428
@@ -160,7 +134,7 @@ type Daemon struct {
160134 done chan struct{}
161135 doneOnce sync.Once
162136
163- sessWg sync.WaitGroup // PTY reader + scheduler goroutines
137+ sessWg sync.WaitGroup // PTY reader goroutines
164138 connWg sync.WaitGroup // per-connection handler goroutines
165139 }
166140
@@ -425,7 +399,7 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
425399 // remaining parked connections, so graceful notices are never raced by the
426400 // force-close. Finally drain the conn handlers, run one defensive sweep in
427401 // 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).
429403 d.shutdownAll(ports.ReasonServerShutdown)
430404 d.waitNotifies()
431405 d.hardCancel()
@@ -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()
M internal/usecase/daemon/floating.go +2 -2
@@ -233,7 +233,7 @@ type floatingLaunchSpec struct {
233233 }
234234
235235 // 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.
237237 func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) {
238238 spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, userOpen)
239239 if err != nil {
@@ -241,7 +241,7 @@ func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfi
241241 return
242242 }
243243 // Count the launch before its goroutine starts. It remains counted through
244- // install, including the reader/scheduler Adds, so shutdown cannot return
244+ // install, including the reader/coordinator Adds, so shutdown cannot return
245245 // before a late Open completion has either been rejected or fully joined.
246246 d.sessWg.Go(func() { d.openAndInstallFloating(sess, tb, spec, generation) })
247247 }
@@ -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 }
M internal/usecase/daemon/floating_render_test.go +0 -35
@@ -268,41 +268,6 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
268268 }
269269 }
270270
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-
306271 func BenchmarkComposeFloatingFrameCached(b *testing.B) {
307272 p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
308273 p.title.displayFallback = "float"
@@ -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 +1 -24
@@ -1,27 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the
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.
1+// Package daemon holds vev's server-side session multiplexer use case.
252 package daemon
263
274 import (
@@ -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/output_state_stream.go +0 -29
@@ -17,8 +17,6 @@ type outputStateStream struct {
1717 renderer *renderer.Renderer
1818 next uint64
1919 acked uint64
20- deferred bool
21- deferredReset bool
2220 maxOutstanding uint64
2321 }
2422
@@ -89,37 +87,10 @@ func (s *outputStateStream) ack(state uint64) {
8987 func (s *outputStateStream) rebase() {
9088 s.acked = s.next
9189 s.renderer.Reset()
92- s.clearDeferred()
9390 }
9491
9592 func (s *outputStateStream) atCapacity() bool {
9693 return s.outstanding() >= s.maxOutstanding
9794 }
9895
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-
12596 func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked }
@@ -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
6868 }
6969 }
7070
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-
11871 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {
11972 t.Helper()
12073 data, err := stream.render(frame, damage, reset)
@@ -124,34 +77,6 @@ func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Fra
12477 return stream.frame(data, reset, echoAck), true, nil
12578 }
12679
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-
15580 func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) {
15681 require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding)
15782 require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding)
@@ -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/paint_gate_test.go +0 -112
@@ -1,14 +1,10 @@
11 package daemon
22
33 import (
4- "io"
54 "testing"
65 "time"
76
87 "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"
128 )
139
1410 func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) {
@@ -25,111 +21,3 @@ func requireNoOutputFrame(t *testing.T, sends chan ports.Frame) {
2521 }
2622 }
2723 }
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-}
@@ -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/pane.go +1 -12
@@ -11,7 +11,7 @@ import (
1111 "github.com/bnema/vev/pkg/vt"
1212 )
1313
14-// pane owns one PTY, its terminal screen state, and render scheduling channels.
14+// pane owns one PTY and its terminal screen state.
1515 // Lock order when multiple locks are held is:
1616 // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu.
1717 // The PTY reader takes only pane.mu, so child output never waits on client IO.
@@ -23,8 +23,6 @@ type pane struct {
2323 resizeMu sync.Mutex // serializes PTY resizes without holding mu
2424 screen *vt.Screen
2525 scrollback *scopy.Scrollback
26- dirty chan struct{}
27- flush chan struct{}
2826 syncGen uint64
2927 rect domain.Rect
3028 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
5149 pty: pty,
5250 screen: screen,
5351 scrollback: sb,
54- dirty: make(chan struct{}, 1),
55- flush: make(chan struct{}, 1),
5652 rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
5753 title: paneTitleState{displayFallback: "sh"},
5854 }
5955 }
60-
61-func paneDone(p *pane) <-chan struct{} {
62- if p.ctx == nil {
63- return nil
64- }
65- return p.ctx.Done()
66-}
@@ -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/picker.go +27 -80
@@ -1,27 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the
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.
1+// Package daemon holds vev's server-side session multiplexer use case.
252 package daemon
263
274 import (
@@ -245,83 +222,53 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
245222 if ac.overlays.picker != nil {
246223 target, ok = ac.overlays.picker.Selected()
247224 }
248- ac.overlays.pickerMu.Unlock()
249-
250225 var next *tab
251226 if ok {
252227 next = d.tabByTarget(target)
253228 }
254-
255- ac.overlays.pickerMu.Lock()
256- old := ac.overlays.pickerPreview
257229 if ac.overlays.picker == nil {
258230 next = nil
259231 }
260232 ac.overlays.pickerPreview = next
261233 ac.overlays.pickerMu.Unlock()
262-
263- if old != nil && old != next {
264- old.mu.Lock()
265- if old.previewClient == ac {
266- old.previewClient = nil
267- }
268- old.mu.Unlock()
269- }
270- if next != nil {
271- next.mu.Lock()
272- next.previewClient = ac
273- next.mu.Unlock()
274-
275- ac.overlays.pickerMu.Lock()
276- keep := ac.overlays.pickerPreview == next
277- ac.overlays.pickerMu.Unlock()
278- if !keep {
279- next.mu.Lock()
280- if next.previewClient == ac {
281- next.previewClient = nil
282- }
283- next.mu.Unlock()
284- }
285- }
286234 }
287235
288236 func (d *Daemon) unregisterPreview(ac *attachedClient) {
289237 ac.overlays.pickerMu.Lock()
290- old := ac.overlays.pickerPreview
291238 ac.overlays.pickerPreview = nil
292239 ac.overlays.pickerMu.Unlock()
293-
294- if old != nil {
295- old.mu.Lock()
296- if old.previewClient == ac {
297- old.previewClient = nil
298- }
299- old.mu.Unlock()
300- }
301240 }
302241
242+// clearDestroyedTabPreview clears coordinator-owned picker state for clients
243+// that were previewing a removed tab. Tabs do not route render ownership.
303244 func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
304- tb.mu.Lock()
305- previewer := tb.previewClient
306- tb.mu.Unlock()
307- if previewer == nil {
245+ if d == nil || d.sessions == nil {
308246 return
309247 }
310- cleared := false
311- previewer.overlays.pickerMu.Lock()
312- if previewer.overlays.pickerPreview == tb {
313- previewer.overlays.pickerPreview = nil
314- cleared = true
315- }
316- previewer.overlays.pickerMu.Unlock()
317- tb.mu.Lock()
318- if tb.previewClient == previewer {
319- tb.previewClient = nil
248+ d.mu.Lock()
249+ var clients []*attachedClient
250+ for _, sess := range d.sessions {
251+ sess.mu.Lock()
252+ if sess.client != nil {
253+ clients = append(clients, sess.client)
254+ }
255+ sess.mu.Unlock()
320256 }
321- tb.mu.Unlock()
322- if cleared {
323- if sess := previewer.currentSession(); sess != nil {
324- d.invalidateRender(sess, previewer, true, "picker.go")
257+ d.mu.Unlock()
258+ for _, ac := range clients {
259+ if ac == nil || ac.overlays == nil {
260+ continue
261+ }
262+ ac.overlays.pickerMu.Lock()
263+ cleared := ac.overlays.pickerPreview == tb
264+ if cleared {
265+ ac.overlays.pickerPreview = nil
266+ }
267+ ac.overlays.pickerMu.Unlock()
268+ if cleared {
269+ if sess := ac.currentSession(); sess != nil {
270+ d.invalidateRender(sess, ac, true, "picker.go")
271+ }
325272 }
326273 }
327274 }
@@ -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 (
@@ -245,83 +222,53 @@ 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 }
248- ac.overlays.pickerMu.Unlock()
249-
250 var next *tab225 var next *tab
251 if ok {226 if ok {
252 next = d.tabByTarget(target)227 next = d.tabByTarget(target)
253 }228 }
254-
255- ac.overlays.pickerMu.Lock()
256- old := ac.overlays.pickerPreview
257 if ac.overlays.picker == nil {229 if ac.overlays.picker == nil {
258 next = nil230 next = nil
259 }231 }
260 ac.overlays.pickerPreview = next232 ac.overlays.pickerPreview = next
261 ac.overlays.pickerMu.Unlock()233 ac.overlays.pickerMu.Unlock()
262-
263- if old != nil && old != next {
264- old.mu.Lock()
265- if old.previewClient == ac {
266- old.previewClient = nil
267- }
268- old.mu.Unlock()
269- }
270- if next != nil {
271- next.mu.Lock()
272- next.previewClient = ac
273- next.mu.Unlock()
274-
275- ac.overlays.pickerMu.Lock()
276- keep := ac.overlays.pickerPreview == next
277- ac.overlays.pickerMu.Unlock()
278- if !keep {
279- next.mu.Lock()
280- if next.previewClient == ac {
281- next.previewClient = nil
282- }
283- next.mu.Unlock()
284- }
285- }
286 }234 }
287 235
288 func (d *Daemon) unregisterPreview(ac *attachedClient) {236 func (d *Daemon) unregisterPreview(ac *attachedClient) {
289 ac.overlays.pickerMu.Lock()237 ac.overlays.pickerMu.Lock()
290- old := ac.overlays.pickerPreview
291 ac.overlays.pickerPreview = nil238 ac.overlays.pickerPreview = nil
292 ac.overlays.pickerMu.Unlock()239 ac.overlays.pickerMu.Unlock()
293-
294- if old != nil {
295- old.mu.Lock()
296- if old.previewClient == ac {
297- old.previewClient = nil
298- }
299- old.mu.Unlock()
300- }
301 }240 }
302 241
242+// clearDestroyedTabPreview clears coordinator-owned picker state for clients
243+// that were previewing a removed tab. Tabs do not route render ownership.
303 func (d *Daemon) clearDestroyedTabPreview(tb *tab) {244 func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
304- tb.mu.Lock()245+ if d == nil || d.sessions == nil {
305- previewer := tb.previewClient
306- tb.mu.Unlock()
307- if previewer == nil {
308 return246 return
309 }247 }
310- cleared := false248+ d.mu.Lock()
311- previewer.overlays.pickerMu.Lock()249+ var clients []*attachedClient
312- if previewer.overlays.pickerPreview == tb {250+ for _, sess := range d.sessions {
313- previewer.overlays.pickerPreview = nil251+ sess.mu.Lock()
314- cleared = true252+ if sess.client != nil {
315- }253+ clients = append(clients, sess.client)
316- previewer.overlays.pickerMu.Unlock()254+ }
317- tb.mu.Lock()255+ sess.mu.Unlock()
318- if tb.previewClient == previewer {
319- tb.previewClient = nil
320 }256 }
321- tb.mu.Unlock()257+ d.mu.Unlock()
322- if cleared {258+ for _, ac := range clients {
323- if sess := previewer.currentSession(); sess != nil {259+ if ac == nil || ac.overlays == nil {
324- d.invalidateRender(sess, previewer, true, "picker.go")260+ continue
261+ }
262+ ac.overlays.pickerMu.Lock()
263+ cleared := ac.overlays.pickerPreview == tb
264+ if cleared {
265+ ac.overlays.pickerPreview = nil
266+ }
267+ ac.overlays.pickerMu.Unlock()
268+ if cleared {
269+ if sess := ac.currentSession(); sess != nil {
270+ d.invalidateRender(sess, ac, true, "picker.go")
271+ }
325 }272 }
326 }273 }
327 }274 }
M internal/usecase/daemon/picker_test.go +0 -118
@@ -2,7 +2,6 @@ package daemon
22
33 import (
44 "context"
5- "sync"
65 "testing"
76 "time"
87
@@ -448,123 +447,6 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {
448447 require.Equal(t, 'R', preview.Rows[2][21].Rune, "expanded stacked pane content should be included")
449448 }
450449
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-
568450 func TestPickerModalGeometry(t *testing.T) {
569451 base := domain.Size{Cols: 100, Rows: 40}
570452
@@ -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
@@ -448,123 +447,6 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {
448 require.Equal(t, 'R', preview.Rows[2][21].Rune, "expanded stacked pane content should be included")447 require.Equal(t, 'R', preview.Rows[2][21].Rune, "expanded stacked pane content should be included")
449 }448 }
450 449
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) {450 func TestPickerModalGeometry(t *testing.T) {
569 base := domain.Size{Cols: 100, Rows: 40}451 base := domain.Size{Cols: 100, Rows: 40}
570 452
M internal/usecase/daemon/render.go +15 -202
@@ -1,34 +1,9 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the
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.
1+// Package daemon holds vev's server-side session multiplexer use case.
252 package daemon
263
274 import (
28- "bytes"
295 "errors"
306 "strconv"
31- "time"
327
338 "github.com/bnema/vev/internal/domain"
349 "github.com/bnema/vev/internal/ports"
@@ -90,23 +65,22 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
9065 }
9166 resp = resp[:0]
9267 }
93- if wasSyncing != isSyncing {
94- p.mu.Lock()
95- p.syncGen++
96- gen := p.syncGen
97- p.mu.Unlock()
98- if isSyncing {
99- go d.syncWatchdog(p, gen)
68+ if rc := sess.renderCoordinator(); rc != nil {
69+ if wasSyncing != isSyncing {
70+ p.mu.Lock()
71+ p.syncGen++
72+ gen := p.syncGen
73+ p.mu.Unlock()
74+ if isSyncing {
75+ rc.noteSyncBegin(gen)
76+ } else {
77+ rc.noteSyncEnd(gen)
78+ }
79+ }
80+ if !isSyncing {
81+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
10082 }
10183 }
102- if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) {
103- signal(p.flush)
104- continue
105- }
106- if isSyncing {
107- continue
108- }
109- signal(p.dirty)
11084 }
11185 if err != nil {
11286 if p.onExit != nil {
@@ -119,159 +93,6 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
11993 }
12094 }
12195
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.invalidateRender(previewSess, previewer, false, "render.go")
254- }
255- if ac != nil && active && ac != previewer {
256- d.invalidateRender(sess, ac, false, "render.go")
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.invalidateRender(sess, ac, false, "render.go")
273-}
274-
27596 // paint draws the composed client frame (active tab plus status bar) and
27697 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
27798 // such as switch/create/close/rename/resize so the repaint is complete.
@@ -362,14 +183,6 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
362183 ac.resizePaintPending = false
363184 reset = true
364185 }
365- // Ack-gated coalescing: while the client is at least maxUnackedOutputStates
366- // behind, skip composing another diff and mark the paint deferred. The
367- // MsgOutputAck handler flushes one cumulative paint once acks catch up. A
368- // deferred reset stays sticky so the cumulative paint is dependency-free.
369- if ac.output.deferIfAtCapacity(reset) {
370- ac.sendMu.Unlock()
371- return
372- }
373186 overlays := ac.overlays.SnapshotForRender()
374187 repaintAttachedClients := false
375188 defer func() {
@@ -1,34 +1,9 @@
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"
29 "errors"5 "errors"
30 "strconv"6 "strconv"
31- "time"
32 7
33 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
34 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
@@ -90,23 +65,22 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
90 }65 }
91 resp = resp[:0]66 resp = resp[:0]
92 }67 }
93- if wasSyncing != isSyncing {68+ if rc := sess.renderCoordinator(); rc != nil {
94- p.mu.Lock()69+ if wasSyncing != isSyncing {
95- p.syncGen++70+ p.mu.Lock()
96- gen := p.syncGen71+ p.syncGen++
97- p.mu.Unlock()72+ gen := p.syncGen
98- if isSyncing {73+ p.mu.Unlock()
99- go d.syncWatchdog(p, gen)74+ if isSyncing {
75+ rc.noteSyncBegin(gen)
76+ } else {
77+ rc.noteSyncEnd(gen)
78+ }
79+ }
80+ if !isSyncing {
81+ rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
100 }82 }
101 }83 }
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 }84 }
111 if err != nil {85 if err != nil {
112 if p.onExit != nil {86 if p.onExit != nil {
@@ -119,159 +93,6 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
119 }93 }
120 }94 }
121 95
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.invalidateRender(previewSess, previewer, false, "render.go")
254- }
255- if ac != nil && active && ac != previewer {
256- d.invalidateRender(sess, ac, false, "render.go")
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.invalidateRender(sess, ac, false, "render.go")
273-}
274-
275 // paint draws the composed client frame (active tab plus status bar) and96 // paint draws the composed client frame (active tab plus status bar) and
276 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations97 // 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.98 // such as switch/create/close/rename/resize so the repaint is complete.
@@ -362,14 +183,6 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
362 ac.resizePaintPending = false183 ac.resizePaintPending = false
363 reset = true184 reset = true
364 }185 }
365- // Ack-gated coalescing: while the client is at least maxUnackedOutputStates
366- // behind, skip composing another diff and mark the paint deferred. The
367- // MsgOutputAck handler flushes one cumulative paint once acks catch up. A
368- // deferred reset stays sticky so the cumulative paint is dependency-free.
369- if ac.output.deferIfAtCapacity(reset) {
370- ac.sendMu.Unlock()
371- return
372- }
373 overlays := ac.overlays.SnapshotForRender()186 overlays := ac.overlays.SnapshotForRender()
374 repaintAttachedClients := false187 repaintAttachedClients := false
375 defer func() {188 defer func() {
M internal/usecase/daemon/render_coordinator.go +3 -1
@@ -321,7 +321,9 @@ func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool,
321321 return
322322 }
323323 }
324- d.paint(sess, ac, reset)
324+ if ac != nil {
325+ d.paint(sess, ac, reset)
326+ }
325327 }
326328
327329 func (c *renderCoordinator) fire(gen uint64, watchdog bool) {
@@ -321,7 +321,9 @@ func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool,
321 return321 return
322 }322 }
323 }323 }
324- d.paint(sess, ac, reset)324+ if ac != nil {
325+ d.paint(sess, ac, reset)
326+ }
325 }327 }
326 328
327 func (c *renderCoordinator) fire(gen uint64, watchdog bool) {329 func (c *renderCoordinator) fire(gen uint64, watchdog bool) {
M internal/usecase/daemon/render_test.go +0 -393
@@ -130,56 +130,6 @@ func TestPTYWriteErrorIsLogged(t *testing.T) {
130130 }
131131 }
132132
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-
183133 func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
184134 d, sess, ac, sends, releases := newManualTabSession(t, 1)
185135 defer releases[0]()
@@ -262,87 +212,6 @@ func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
262212 d.sessWg.Wait()
263213 }
264214
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-
346215 // --- resize ordering --------------------------------------------------------
347216
348217 func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
@@ -451,39 +320,6 @@ func TestTranslatePaneDamagePreservesFullWidthScrollFastPathOnly(t *testing.T) {
451320 }
452321 }
453322
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-
487323 func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {
488324 win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
489325 left := win.focusedPane()
@@ -977,75 +813,6 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
977813 d.waitNotifies()
978814 }
979815
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-
1049816 func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
1050817 d := newTestDaemon(t, nil, stubClock{})
1051818 p := portsmocks.NewMockPTY(t)
@@ -1089,166 +856,6 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
1089856 }
1090857 }
1091858
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-
1252859 func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
1253860 p, _ := newBlockingPTY(t)
1254861 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -130,56 +130,6 @@ func TestPTYWriteErrorIsLogged(t *testing.T) {
130 }130 }
131 }131 }
132 132
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) {133 func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
184 d, sess, ac, sends, releases := newManualTabSession(t, 1)134 d, sess, ac, sends, releases := newManualTabSession(t, 1)
185 defer releases[0]()135 defer releases[0]()
@@ -262,87 +212,6 @@ func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
262 d.sessWg.Wait()212 d.sessWg.Wait()
263 }213 }
264 214
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 --------------------------------------------------------215 // --- resize ordering --------------------------------------------------------
347 216
348 func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {217 func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
@@ -451,39 +320,6 @@ func TestTranslatePaneDamagePreservesFullWidthScrollFastPathOnly(t *testing.T) {
451 }320 }
452 }321 }
453 322
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) {323 func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {
488 win := newTab(nil, domain.Size{Cols: 41, Rows: 4})324 win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
489 left := win.focusedPane()325 left := win.focusedPane()
@@ -977,75 +813,6 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
977 d.waitNotifies()813 d.waitNotifies()
978 }814 }
979 815
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) {816 func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
1050 d := newTestDaemon(t, nil, stubClock{})817 d := newTestDaemon(t, nil, stubClock{})
1051 p := portsmocks.NewMockPTY(t)818 p := portsmocks.NewMockPTY(t)
@@ -1089,166 +856,6 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
1089 }856 }
1090 }857 }
1091 858
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) {859 func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
1253 p, _ := newBlockingPTY(t)860 p, _ := newBlockingPTY(t)
1254 d, sess, ac, sends := newManualSessionWithPTYs(t, p)861 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
M internal/usecase/daemon/resume.go +0 -1
@@ -52,7 +52,6 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
5252 // deferred diff and any resize-idle callback before registering its grace
5353 // timer; resume performs a fresh full paint.
5454 ac.cancelResizePaint()
55- ac.clearPaintDeferred()
5655 d.mu.Lock()
5756 if d.closing {
5857 d.mu.Unlock()
@@ -52,7 +52,6 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
52 // deferred diff and any resize-idle callback before registering its grace52 // deferred diff and any resize-idle callback before registering its grace
53 // timer; resume performs a fresh full paint.53 // timer; resume performs 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()
M internal/usecase/daemon/session.go +3 -30
@@ -1,27 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the
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.
1+// Package daemon holds vev's server-side session multiplexer use case.
252 package daemon
263
274 import (
@@ -77,7 +54,7 @@ type session struct {
7754
7855 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
7956 type tab struct {
80- mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, previewClient, and pane map membership
57+ mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership
8158
8259 stableID string
8360 name string
@@ -90,9 +67,6 @@ type tab struct {
9067 // floating is independent from the normal layout tree and pane map.
9168 floating floatingSlot
9269
93- // previewClient tracks the one client currently previewing this tab in the picker.
94- // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
95- previewClient *attachedClient
9670 // attention fields are guarded by the owning session.mu.
9771 attention bool
9872 attentionAt time.Time
@@ -102,7 +76,7 @@ type tab struct {
10276 // attachedClient is a client currently attached to a session's tab. rend is
10377 // its private renderer shadow (so each client gets output minimised against
10478 // what it has actually seen). sendMu serialises the two senders — the render
105-// scheduler and the connection handler — so the transport's single-writer
79+// coordinator and the connection handler — so the transport's single-writer
10680
10781 func (d *Daemon) touchMRU(sess *session) {
10882 if d == nil || sess == nil {
@@ -422,7 +396,6 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
422396 }
423397 d.sessWg.Add(2)
424398 go d.ptyReader(sess, tb, p)
425- go d.scheduler(sess, tb, p)
426399 }
427400
428401 // attachClient makes ac the session's current client, displacing any prior one
@@ -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 (
@@ -77,7 +54,7 @@ type session struct {
77 54
78 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.55 // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
79 type tab struct {56 type tab struct {
80- mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, previewClient, and pane map membership57+ mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership
81 58
82 stableID string59 stableID string
83 name string60 name string
@@ -90,9 +67,6 @@ type tab struct {
90 // floating is independent from the normal layout tree and pane map.67 // floating is independent from the normal layout tree and pane map.
91 floating floatingSlot68 floating floatingSlot
92 69
93- // previewClient tracks the one client currently previewing this tab in the picker.
94- // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
95- previewClient *attachedClient
96 // attention fields are guarded by the owning session.mu.70 // attention fields are guarded by the owning session.mu.
97 attention bool71 attention bool
98 attentionAt time.Time72 attentionAt time.Time
@@ -102,7 +76,7 @@ type tab struct {
102 // attachedClient is a client currently attached to a session's tab. rend is76 // attachedClient is a client currently attached to a session's tab. rend is
103 // its private renderer shadow (so each client gets output minimised against77 // its private renderer shadow (so each client gets output minimised against
104 // what it has actually seen). sendMu serialises the two senders — the render78 // what it has actually seen). sendMu serialises the two senders — the render
105-// scheduler and the connection handler — so the transport's single-writer79+// coordinator and the connection handler — so the transport's single-writer
106 80
107 func (d *Daemon) touchMRU(sess *session) {81 func (d *Daemon) touchMRU(sess *session) {
108 if d == nil || sess == nil {82 if d == nil || sess == nil {
@@ -422,7 +396,6 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
422 }396 }
423 d.sessWg.Add(2)397 d.sessWg.Add(2)
424 go d.ptyReader(sess, tb, p)398 go d.ptyReader(sess, tb, p)
425- go d.scheduler(sess, tb, p)
426 }399 }
427 400
428 // attachClient makes ac the session's current client, displacing any prior one401 // attachClient makes ac the session's current client, displacing any prior one
M internal/usecase/daemon/status.go +1 -24
@@ -1,27 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the
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.
1+// Package daemon holds vev's server-side session multiplexer use case.
252 package daemon
263
274 import (
@@ -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/wire.go +1 -24
@@ -1,27 +1,4 @@
1-// Package daemon holds vev's server-side session multiplexer use case: the
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.
1+// Package daemon holds vev's server-side session multiplexer use case.
252 package daemon
263
274 import (
@@ -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