refactor(daemon): remove legacy render ownership
- commit
- 37d1a50fc98947d2ec53cb3081a851fe0b1b548d
- author
- brice · · 2026-07-11 15:20 UTC
- parent
- 0e2e6e9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 306 | 306 | if sess == nil { | |
| 307 | 307 | return | |
| 308 | 308 | } | |
| 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") | |
| 316 | 314 | } | |
| 317 | 315 | } | |
| @@ -306,12 +306,10 @@ func (d *Daemon) pokeSessionRender(sess *session) { | |||||
| 306 | if sess == nil { | 306 | if sess == nil { | ||
| 307 | return | 307 | 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. | |
| 25 | 2 | package daemon | |
| 26 | 3 | ||
| 27 | 4 | import ( | |
| @@ -197,23 +174,6 @@ func (ac *attachedClient) ackOutputState(state uint64) { | |||
| 197 | 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 | 177 | // send serialises a frame onto the client's transport. | |
| 218 | 178 | func (ac *attachedClient) send(f ports.Frame) error { | |
| 219 | 179 | _, err := ac.sendTransport(f) | |
| @@ -401,10 +361,27 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||
| 401 | 361 | rc := sess.renderCoordinator() | |
| 402 | 362 | if rc == nil { | |
| 403 | 363 | 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 | + | }, | |
| 408 | 385 | }) | |
| 409 | 386 | sess.installRenderCoordinator(rc) | |
| 410 | 387 | } | |
| @@ -519,8 +496,8 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 519 | 496 | case ports.MsgAck: | |
| 520 | 497 | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | |
| 521 | 498 | 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() | |
| 524 | 501 | } | |
| 525 | 502 | } | |
| 526 | 503 | case ports.MsgPing: | |
| @@ -1,27 +1,4 @@ | |||||
| 1 | - | // Package daemon holds vev's server-side session multiplexer use case: the | 1 | + | // 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 daemon | 2 | 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 @@ | |||
| 1 | 1 | // Package daemon holds vev's server-side session multiplexer use case: the | |
| 2 | 2 | // 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 | 4 | package daemon | |
| 5 | 5 | ||
| 6 | 6 | import ( | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | // Package daemon holds vev's server-side session multiplexer use case: the | 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 | 2 | // 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 daemon | 4 | 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. | |
| 25 | 2 | package daemon | |
| 26 | 3 | ||
| 27 | 4 | import ( | |
| @@ -1,27 +1,4 @@ | |||||
| 1 | - | // Package daemon holds vev's server-side session multiplexer use case: the | 1 | + | // 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 daemon | 2 | 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. | |
| 27 | 2 | package daemon | |
| 28 | 3 | ||
| 29 | 4 | import ( | |
| @@ -48,7 +23,6 @@ import ( | |||
| 48 | 23 | const ( | |
| 49 | 24 | minDebounceInterval = 2 * time.Millisecond | |
| 50 | 25 | maxDebounceInterval = 16 * time.Millisecond | |
| 51 | - | debounceStep = 2 * time.Millisecond | |
| 52 | 26 | maxSyncUpdateDuration = 500 * time.Millisecond | |
| 53 | 27 | ) | |
| 54 | 28 | ||
| @@ -160,7 +134,7 @@ type Daemon struct { | |||
| 160 | 134 | done chan struct{} | |
| 161 | 135 | doneOnce sync.Once | |
| 162 | 136 | ||
| 163 | - | sessWg sync.WaitGroup // PTY reader + scheduler goroutines | |
| 137 | + | sessWg sync.WaitGroup // PTY reader goroutines | |
| 164 | 138 | 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 | 399 | // remaining parked connections, so graceful notices are never raced by the | |
| 426 | 400 | // force-close. Finally drain the conn handlers, run one defensive sweep in | |
| 427 | 401 | // 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 | 403 | d.shutdownAll(ports.ReasonServerShutdown) | |
| 430 | 404 | d.waitNotifies() | |
| 431 | 405 | d.hardCancel() | |
| @@ -1,29 +1,4 @@ | |||||
| 1 | - | // Package daemon holds vev's server-side session multiplexer use case: the | 1 | + | // 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 daemon | 2 | package daemon | ||
| 28 | 3 | ||||
| 29 | import ( | 4 | import ( | ||
| @@ -48,7 +23,6 @@ import ( | |||||
| 48 | const ( | 23 | const ( | ||
| 49 | minDebounceInterval = 2 * time.Millisecond | 24 | minDebounceInterval = 2 * time.Millisecond | ||
| 50 | maxDebounceInterval = 16 * time.Millisecond | 25 | maxDebounceInterval = 16 * time.Millisecond | ||
| 51 | - | debounceStep = 2 * time.Millisecond | |||
| 52 | maxSyncUpdateDuration = 500 * time.Millisecond | 26 | 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.Once | 135 | doneOnce sync.Once | ||
| 162 | 136 | ||||
| 163 | - | sessWg sync.WaitGroup // PTY reader + scheduler goroutines | 137 | + | sessWg sync.WaitGroup // PTY reader goroutines |
| 164 | connWg sync.WaitGroup // per-connection handler goroutines | 138 | 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 the | 399 | // remaining parked connections, so graceful notices are never raced by the | ||
| 426 | // force-close. Finally drain the conn handlers, run one defensive sweep in | 400 | // force-close. Finally drain the conn handlers, run one defensive sweep in | ||
| 427 | // case any ordering ever leaves a session behind, and join the session | 401 | // 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 { | |||
| 233 | 233 | } | |
| 234 | 234 | ||
| 235 | 235 | // 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 | 237 | func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfig, generation uint64, userOpen bool) { | |
| 238 | 238 | spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, userOpen) | |
| 239 | 239 | if err != nil { | |
| @@ -241,7 +241,7 @@ func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfi | |||
| 241 | 241 | return | |
| 242 | 242 | } | |
| 243 | 243 | // 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 | |
| 245 | 245 | // before a late Open completion has either been rejected or fully joined. | |
| 246 | 246 | d.sessWg.Go(func() { d.openAndInstallFloating(sess, tb, spec, generation) }) | |
| 247 | 247 | } | |
| @@ -233,7 +233,7 @@ type floatingLaunchSpec struct { | |||||
| 233 | } | 233 | } | ||
| 234 | 234 | ||||
| 235 | // launchFloating snapshots launch inputs, then accounts for a worker through | 235 | // 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 | return | 241 | return | ||
| 242 | } | 242 | } | ||
| 243 | // Count the launch before its goroutine starts. It remains counted through | 243 | // 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 |
| 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) { | |||
| 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 | 271 | func BenchmarkComposeFloatingFrameCached(b *testing.B) { | |
| 307 | 272 | p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18}) | |
| 308 | 273 | 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. | |
| 25 | 2 | package daemon | |
| 26 | 3 | ||
| 27 | 4 | import ( | |
| @@ -1,27 +1,4 @@ | |||||
| 1 | - | // Package daemon holds vev's server-side session multiplexer use case: the | 1 | + | // 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 daemon | 2 | 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 { | |||
| 17 | 17 | renderer *renderer.Renderer | |
| 18 | 18 | next uint64 | |
| 19 | 19 | acked uint64 | |
| 20 | - | deferred bool | |
| 21 | - | deferredReset bool | |
| 22 | 20 | maxOutstanding uint64 | |
| 23 | 21 | } | |
| 24 | 22 | ||
| @@ -89,37 +87,10 @@ func (s *outputStateStream) ack(state uint64) { | |||
| 89 | 87 | func (s *outputStateStream) rebase() { | |
| 90 | 88 | s.acked = s.next | |
| 91 | 89 | s.renderer.Reset() | |
| 92 | - | s.clearDeferred() | |
| 93 | 90 | } | |
| 94 | 91 | ||
| 95 | 92 | func (s *outputStateStream) atCapacity() bool { | |
| 96 | 93 | 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 | 96 | func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked } | |
| @@ -17,8 +17,6 @@ type outputStateStream struct { | |||||
| 17 | renderer *renderer.Renderer | 17 | renderer *renderer.Renderer | ||
| 18 | next uint64 | 18 | next uint64 | ||
| 19 | acked uint64 | 19 | acked uint64 | ||
| 20 | - | deferred bool | |||
| 21 | - | deferredReset bool | |||
| 22 | maxOutstanding uint64 | 20 | 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.next | 88 | 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.maxOutstanding | 93 | return s.outstanding() >= s.maxOutstanding | ||
| 97 | } | 94 | } | ||
| 98 | 95 | ||||
| 99 | - | func (s *outputStateStream) deferIfAtCapacity(reset bool) bool { | |||
| 100 | - | if !s.atCapacity() { | |||
| 101 | - | if reset { | |||
| 102 | - | s.clearDeferred() | |||
| 103 | - | } | |||
| 104 | - | return false | |||
| 105 | - | } | |||
| 106 | - | s.deferred = true | |||
| 107 | - | s.deferredReset = s.deferredReset || reset | |||
| 108 | - | return true | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | func (s *outputStateStream) takeDeferred() (reset bool, ok bool) { | |||
| 112 | - | if !s.deferred || s.atCapacity() { | |||
| 113 | - | return false, false | |||
| 114 | - | } | |||
| 115 | - | reset = s.deferredReset | |||
| 116 | - | s.clearDeferred() | |||
| 117 | - | return reset, true | |||
| 118 | - | } | |||
| 119 | - | ||||
| 120 | - | func (s *outputStateStream) clearDeferred() { | |||
| 121 | - | s.deferred = false | |||
| 122 | - | s.deferredReset = false | |||
| 123 | - | } | |||
| 124 | - | ||||
| 125 | func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked } | 96 | func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked } | ||
M internal/usecase/daemon/output_state_stream_test.go +0 -75
| @@ -68,53 +68,6 @@ func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T | |||
| 68 | 68 | } | |
| 69 | 69 | } | |
| 70 | 70 | ||
| 71 | - | func TestOutputStateStreamAckProgressionReleasesDeferredPaint(t *testing.T) { | |
| 72 | - | stream := newOutputStateStream() | |
| 73 | - | for range maxUnackedOutputStates { | |
| 74 | - | stream.frame([]byte("output"), false, 0) | |
| 75 | - | } | |
| 76 | - | require.True(t, stream.deferIfAtCapacity(false)) | |
| 77 | - | _, ok := stream.takeDeferred() | |
| 78 | - | require.False(t, ok) | |
| 79 | - | stream.ack(maxUnackedOutputStates - 1) | |
| 80 | - | reset, ok := stream.takeDeferred() | |
| 81 | - | require.True(t, ok) | |
| 82 | - | require.False(t, reset) | |
| 83 | - | require.Equal(t, uint64(1), stream.outstanding()) | |
| 84 | - | ||
| 85 | - | // Stale and future ACKs do not move the cumulative acknowledgement. | |
| 86 | - | stream.ack(maxUnackedOutputStates - 2) | |
| 87 | - | stream.ack(maxUnackedOutputStates + 1) | |
| 88 | - | require.Equal(t, uint64(maxUnackedOutputStates-1), stream.acked) | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | func TestOutputStateStreamResetIsDependencyFreeAndClearsDeferral(t *testing.T) { | |
| 92 | - | stream := newOutputStateStream() | |
| 93 | - | for range maxUnackedOutputStates { | |
| 94 | - | stream.frame([]byte("output"), false, 0) | |
| 95 | - | } | |
| 96 | - | require.True(t, stream.deferIfAtCapacity(false)) | |
| 97 | - | for range 32 { | |
| 98 | - | require.True(t, stream.deferIfAtCapacity(true), "reset paints must coalesce while the stream is at capacity") | |
| 99 | - | } | |
| 100 | - | require.Equal(t, uint64(maxUnackedOutputStates), stream.outstanding()) | |
| 101 | - | stream.ack(maxUnackedOutputStates) | |
| 102 | - | reset, ok := stream.takeDeferred() | |
| 103 | - | require.True(t, ok) | |
| 104 | - | require.True(t, reset, "a coalesced reset must flush as a dependency-free repaint") | |
| 105 | - | ||
| 106 | - | frame := renderer.NewFrame(2, 1) | |
| 107 | - | fillOutputStateRows(frame, []string{"ok"}) | |
| 108 | - | outFrame, ok, err := drawOutputState(t, stream, frame, nil, true, 0) | |
| 109 | - | require.NoError(t, err) | |
| 110 | - | require.True(t, ok) | |
| 111 | - | out, err := ports.UnmarshalOutput(outFrame.Payload) | |
| 112 | - | require.NoError(t, err) | |
| 113 | - | require.Zero(t, out.BaseStateNum) | |
| 114 | - | _, ok = stream.takeDeferred() | |
| 115 | - | require.False(t, ok) | |
| 116 | - | } | |
| 117 | - | ||
| 118 | 71 | func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) { | |
| 119 | 72 | t.Helper() | |
| 120 | 73 | data, err := stream.render(frame, damage, reset) | |
| @@ -124,34 +77,6 @@ func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Fra | |||
| 124 | 77 | 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 | 80 | func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) { | |
| 156 | 81 | require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding) | |
| 157 | 82 | 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, nil | 77 | 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "io" | |
| 5 | 4 | "testing" | |
| 6 | 5 | "time" | |
| 7 | 6 | ||
| 8 | 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 | 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 | - | } | |
| @@ -1,14 +1,10 @@ | |||||
| 1 | package daemon | 1 | 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 ( | |||
| 11 | 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 | 15 | // Lock order when multiple locks are held is: | |
| 16 | 16 | // attachedClient.sendMu > Daemon.mu > session.mu > tab.mu > pane.mu. | |
| 17 | 17 | // The PTY reader takes only pane.mu, so child output never waits on client IO. | |
| @@ -23,8 +23,6 @@ type pane struct { | |||
| 23 | 23 | resizeMu sync.Mutex // serializes PTY resizes without holding mu | |
| 24 | 24 | screen *vt.Screen | |
| 25 | 25 | scrollback *scopy.Scrollback | |
| 26 | - | dirty chan struct{} | |
| 27 | - | flush chan struct{} | |
| 28 | 26 | syncGen uint64 | |
| 29 | 27 | rect domain.Rect | |
| 30 | 28 | 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 | 49 | pty: pty, | |
| 52 | 50 | screen: screen, | |
| 53 | 51 | scrollback: sb, | |
| 54 | - | dirty: make(chan struct{}, 1), | |
| 55 | - | flush: make(chan struct{}, 1), | |
| 56 | 52 | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, | |
| 57 | 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 | - | } | |
| @@ -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 mu | 23 | resizeMu sync.Mutex // serializes PTY resizes without holding mu | ||
| 24 | screen *vt.Screen | 24 | screen *vt.Screen | ||
| 25 | scrollback *scopy.Scrollback | 25 | scrollback *scopy.Scrollback | ||
| 26 | - | dirty chan struct{} | |||
| 27 | - | flush chan struct{} | |||
| 28 | syncGen uint64 | 26 | syncGen uint64 | ||
| 29 | rect domain.Rect | 27 | rect domain.Rect | ||
| 30 | popupGeometry floatingGeometry // last geometry committed after a successful floating resize | 28 | 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. | |
| 25 | 2 | package daemon | |
| 26 | 3 | ||
| 27 | 4 | import ( | |
| @@ -245,83 +222,53 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||
| 245 | 222 | if ac.overlays.picker != nil { | |
| 246 | 223 | target, ok = ac.overlays.picker.Selected() | |
| 247 | 224 | } | |
| 248 | - | ac.overlays.pickerMu.Unlock() | |
| 249 | - | ||
| 250 | 225 | var next *tab | |
| 251 | 226 | if ok { | |
| 252 | 227 | next = d.tabByTarget(target) | |
| 253 | 228 | } | |
| 254 | - | ||
| 255 | - | ac.overlays.pickerMu.Lock() | |
| 256 | - | old := ac.overlays.pickerPreview | |
| 257 | 229 | if ac.overlays.picker == nil { | |
| 258 | 230 | next = nil | |
| 259 | 231 | } | |
| 260 | 232 | ac.overlays.pickerPreview = next | |
| 261 | 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 | 236 | func (d *Daemon) unregisterPreview(ac *attachedClient) { | |
| 289 | 237 | ac.overlays.pickerMu.Lock() | |
| 290 | - | old := ac.overlays.pickerPreview | |
| 291 | 238 | ac.overlays.pickerPreview = nil | |
| 292 | 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 | 244 | 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 { | |
| 308 | 246 | return | |
| 309 | 247 | } | |
| 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() | |
| 320 | 256 | } | |
| 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 | + | } | |
| 325 | 272 | } | |
| 326 | 273 | } | |
| 327 | 274 | } | |
| @@ -1,27 +1,4 @@ | |||||
| 1 | - | // Package daemon holds vev's server-side session multiplexer use case: the | 1 | + | // 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 daemon | 2 | 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 *tab | 225 | 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 = nil | 230 | next = nil | ||
| 259 | } | 231 | } | ||
| 260 | ac.overlays.pickerPreview = next | 232 | 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 = nil | 238 | 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 | return | 246 | return | ||
| 309 | } | 247 | } | ||
| 310 | - | cleared := false | 248 | + | 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 = nil | 251 | + | sess.mu.Lock() |
| 314 | - | cleared = true | 252 | + | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "sync" | |
| 6 | 5 | "testing" | |
| 7 | 6 | "time" | |
| 8 | 7 | ||
| @@ -448,123 +447,6 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) { | |||
| 448 | 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 | 450 | func TestPickerModalGeometry(t *testing.T) { | |
| 569 | 451 | base := domain.Size{Cols: 100, Rows: 40} | |
| 570 | 452 | ||
| @@ -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. | |
| 25 | 2 | package daemon | |
| 26 | 3 | ||
| 27 | 4 | import ( | |
| 28 | - | "bytes" | |
| 29 | 5 | "errors" | |
| 30 | 6 | "strconv" | |
| 31 | - | "time" | |
| 32 | 7 | ||
| 33 | 8 | "github.com/bnema/vev/internal/domain" | |
| 34 | 9 | "github.com/bnema/vev/internal/ports" | |
| @@ -90,23 +65,22 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||
| 90 | 65 | } | |
| 91 | 66 | resp = resp[:0] | |
| 92 | 67 | } | |
| 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"}) | |
| 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 | 85 | if err != nil { | |
| 112 | 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 | 96 | // paint draws the composed client frame (active tab plus status bar) and | |
| 276 | 97 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | |
| 277 | 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 | 183 | ac.resizePaintPending = false | |
| 363 | 184 | 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 | 186 | overlays := ac.overlays.SnapshotForRender() | |
| 374 | 187 | repaintAttachedClients := false | |
| 375 | 188 | defer func() { | |
| @@ -1,34 +1,9 @@ | |||||
| 1 | - | // Package daemon holds vev's server-side session multiplexer use case: the | 1 | + | // 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 daemon | 2 | 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.syncGen | 71 | + | 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) and | 96 | // paint draws the composed client frame (active tab plus status bar) and | ||
| 276 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | 97 | // 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 = false | 183 | ac.resizePaintPending = false | ||
| 363 | reset = true | 184 | 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 := false | 187 | 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, | |||
| 321 | 321 | 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 | 329 | func (c *renderCoordinator) fire(gen uint64, watchdog bool) { | |
| @@ -321,7 +321,9 @@ func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool, | |||||
| 321 | return | 321 | 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) { | |||
| 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 | 133 | func TestAltXClosesFinalTabAndDetaches(t *testing.T) { | |
| 184 | 134 | d, sess, ac, sends, releases := newManualTabSession(t, 1) | |
| 185 | 135 | defer releases[0]() | |
| @@ -262,87 +212,6 @@ func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) { | |||
| 262 | 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 | 215 | // --- resize ordering -------------------------------------------------------- | |
| 347 | 216 | ||
| 348 | 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 | 323 | func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) { | |
| 488 | 324 | win := newTab(nil, domain.Size{Cols: 41, Rows: 4}) | |
| 489 | 325 | left := win.focusedPane() | |
| @@ -977,75 +813,6 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) { | |||
| 977 | 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 | 816 | func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | |
| 1050 | 817 | d := newTestDaemon(t, nil, stubClock{}) | |
| 1051 | 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 | 859 | func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | |
| 1253 | 860 | p, _ := newBlockingPTY(t) | |
| 1254 | 861 | 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 { | |||
| 52 | 52 | // deferred diff and any resize-idle callback before registering its grace | |
| 53 | 53 | // timer; resume performs a fresh full paint. | |
| 54 | 54 | ac.cancelResizePaint() | |
| 55 | - | ac.clearPaintDeferred() | |
| 56 | 55 | d.mu.Lock() | |
| 57 | 56 | if d.closing { | |
| 58 | 57 | 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 grace | 52 | // 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. | |
| 25 | 2 | package daemon | |
| 26 | 3 | ||
| 27 | 4 | import ( | |
| @@ -77,7 +54,7 @@ type session struct { | |||
| 77 | 54 | ||
| 78 | 55 | // tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state. | |
| 79 | 56 | 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 | |
| 81 | 58 | ||
| 82 | 59 | stableID string | |
| 83 | 60 | name string | |
| @@ -90,9 +67,6 @@ type tab struct { | |||
| 90 | 67 | // floating is independent from the normal layout tree and pane map. | |
| 91 | 68 | 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 | 70 | // attention fields are guarded by the owning session.mu. | |
| 97 | 71 | attention bool | |
| 98 | 72 | attentionAt time.Time | |
| @@ -102,7 +76,7 @@ type tab struct { | |||
| 102 | 76 | // attachedClient is a client currently attached to a session's tab. rend is | |
| 103 | 77 | // its private renderer shadow (so each client gets output minimised against | |
| 104 | 78 | // 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 | |
| 106 | 80 | ||
| 107 | 81 | func (d *Daemon) touchMRU(sess *session) { | |
| 108 | 82 | if d == nil || sess == nil { | |
| @@ -422,7 +396,6 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) { | |||
| 422 | 396 | } | |
| 423 | 397 | d.sessWg.Add(2) | |
| 424 | 398 | go d.ptyReader(sess, tb, p) | |
| 425 | - | go d.scheduler(sess, tb, p) | |
| 426 | 399 | } | |
| 427 | 400 | ||
| 428 | 401 | // 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: the | 1 | + | // 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 daemon | 2 | 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 membership | 57 | + | mu sync.Mutex // guards tree, panes, floating, nextPaneID, size, and pane map membership |
| 81 | 58 | ||||
| 82 | stableID string | 59 | stableID string | ||
| 83 | name string | 60 | 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 floatingSlot | 68 | 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 bool | 71 | attention bool | ||
| 98 | attentionAt time.Time | 72 | attentionAt time.Time | ||
| @@ -102,7 +76,7 @@ type tab struct { | |||||
| 102 | // attachedClient is a client currently attached to a session's tab. rend is | 76 | // attachedClient is a client currently attached to a session's tab. rend is | ||
| 103 | // its private renderer shadow (so each client gets output minimised against | 77 | // its private renderer shadow (so each client gets output minimised against | ||
| 104 | // what it has actually seen). sendMu serialises the two senders — the render | 78 | // 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 |
| 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 one | 401 | // 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. | |
| 25 | 2 | package daemon | |
| 26 | 3 | ||
| 27 | 4 | import ( | |
| @@ -1,27 +1,4 @@ | |||||
| 1 | - | // Package daemon holds vev's server-side session multiplexer use case: the | 1 | + | // 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 daemon | 2 | 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. | |
| 25 | 2 | package daemon | |
| 26 | 3 | ||
| 27 | 4 | import ( | |
| @@ -1,27 +1,4 @@ | |||||
| 1 | - | // Package daemon holds vev's server-side session multiplexer use case: the | 1 | + | // 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 daemon | 2 | package daemon | ||
| 26 | 3 | ||||
| 27 | import ( | 4 | import ( | ||