1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5 "bytes"
6
7 renderer "github.com/bnema/vev-vt/ansi"
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/layout"
11 themeui "github.com/bnema/vev/internal/usecase/theme"
12)
13
14// completedSynchronizedUpdate reports a full DEC 2026 batch contained in one
15// PTY read. The VT's final state is inactive in this case, so it cannot be
16// inferred from before/after SyncUpdateActive alone.
17func completedSynchronizedUpdate(data []byte) bool {
18 _, tail, found := bytes.Cut(data, []byte(renderer.SyncStartCSI))
19 return found && bytes.Contains(tail, []byte(renderer.SyncEndCSI))
20}
21
22// paneRenderable reports dynamic composition visibility. Callers must not
23// hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks.
24func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
25 if sess == nil || tb == nil || p == nil {
26 return false
27 }
28 sess.mu.Lock()
29 active := sess.attachmentViewsTabLocked(tb)
30 attached := len(sess.attachments) != 0
31 sess.mu.Unlock()
32
33 // The normal attached render path needs no cross-session picker lookup.
34 // Only inactive or headless tabs can be renderable as a picker preview.
35 if (!active || !attached) && !d.tabIsPickerPreview(tb) {
36 return false
37 }
38 tb.mu.Lock()
39 defer tb.mu.Unlock()
40 if tb.floating.pane == p {
41 return active && attached && tb.floating.state == floatingVisible
42 }
43 placements, ok := solvedPlacementsLocked(tb)
44 if !ok {
45 return false
46 }
47 for _, placement := range placements {
48 if placement.ID == p.id && !placement.Collapsed && placement.Content.Width > 0 && placement.Content.Height > 0 {
49 return true
50 }
51 }
52 return false
53}
54
55// tabIsPickerPreview reports whether any attached client currently composes
56// tb as a picker preview. It snapshots daemon ownership before taking overlay
57// locks, preserving the Daemon -> session -> tab -> pane lock order.
58func (d *Daemon) tabIsPickerPreview(tb *tab) bool {
59 if d == nil || tb == nil {
60 return false
61 }
62 d.mu.Lock()
63 sessions := sessionsSnapshot(d.sessions)
64 d.mu.Unlock()
65 for _, sess := range sessions {
66 for _, ac := range sess.snapshotAttachments() {
67 if ac == nil || ac.overlays == nil {
68 continue
69 }
70 ac.overlays.pickerMu.Lock()
71 preview := ac.overlays.pickerPreview == tb
72 ac.overlays.pickerMu.Unlock()
73 if preview {
74 return true
75 }
76 }
77 }
78 return false
79}
80
81// ptyReader is retained as a compatibility entry point for focused tests. The
82// production goroutine calls readPanePTY, whose closure owns only the pane.
83func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
84 if p != nil && p.ownerSnapshot() == nil && sess != nil && tb != nil {
85 publishPaneOwner(p, sess, tb, 0)
86 }
87 d.readPanePTY(p)
88}
89
90func (d *Daemon) readPanePTY(p *pane) {
91 defer d.sessWg.Done()
92 if p == nil {
93 return
94 }
95 buf := make([]byte, ptyReadBufSize)
96 p.mu.Lock()
97 p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
98 p.screen.OnBell = func() { p.ptyAttention = true }
99 p.screen.OnNotify = func(string, string) { p.ptyAttention = true }
100 p.screen.OnProgress = func(bool) { p.ptyAttention = true }
101 p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
102 p.mu.Unlock()
103 for {
104 n, err := p.pty.Read(buf)
105 if n > 0 {
106 d.processPanePTYData(p, buf[:n], true)
107 }
108 if err != nil {
109 lease := p.effectLease()
110 if sessionName, current := paneEffectSessionName(lease); current {
111 d.log.Info("pane pty closed", "err", err, "session", sessionName)
112 } else {
113 d.log.Info("pane pty closed", "err", err)
114 }
115 if p.onExit != nil {
116 p.onExit()
117 } else {
118 d.reapPaneOwner(p)
119 }
120 return
121 }
122 }
123}
124
125type panePTYEffects struct {
126 lease paneEffectLease
127 renderCoordinator *renderCoordinator
128 wasSyncing bool
129 isSyncing bool
130 completeSyncRead bool
131 syncEnded bool
132 attention bool
133 responses []byte
134 clipboards []string
135 syncCleanup syncTimerCleanup
136}
137
138// processPTYData retains the old call shape for resize replay and focused tests,
139// but routing is always derived from the pane owner captured under pane.mu.
140func (d *Daemon) processPTYData(_ *session, _ *tab, p *pane, data []byte, bufferDuringApply bool) {
141 d.processPanePTYData(p, data, bufferDuringApply)
142}
143
144// processPanePTYData is the sole VT parsing path. While a resize apply owns the
145// pane, reader calls append exact bytes and returns immediately; it never waits
146// for PTY.Resize or loses a short read.
147func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool) {
148 if p == nil {
149 return
150 }
151 p.mu.Lock()
152 if bufferDuringApply && p.resizeApplying {
153 p.resizePending = append(p.resizePending, data...)
154 p.mu.Unlock()
155 return
156 }
157 effects := panePTYEffects{lease: p.effectLeaseLocked()}
158 owner := effects.lease.owner
159 if owner != nil && owner.session != nil {
160 effects.renderCoordinator = owner.session.renderCoordinator()
161 }
162 effects.wasSyncing = p.screen.SyncUpdateActive()
163 p.screen.Write(data)
164 p.refreshTerminalTitleLocked()
165 effects.isSyncing = p.screen.SyncUpdateActive()
166 effects.completeSyncRead = !effects.wasSyncing && !effects.isSyncing && completedSynchronizedUpdate(data)
167 if effects.wasSyncing != effects.isSyncing && owner != nil && owner.session != nil {
168 if effects.isSyncing {
169 syncGen := owner.session.syncGen.Add(1)
170 p.syncGen = syncGen
171 if effects.renderCoordinator != nil {
172 lease := effects.lease
173 effects.syncCleanup = effects.renderCoordinator.beginSyncBatchWithRenderability(p, syncGen, func() bool {
174 return lease.Current() && d.paneRenderable(owner.session, owner.tab, p)
175 }, func() {
176 p.mu.Lock()
177 if p.owner.Load() == lease.owner && p.syncGen == syncGen && p.screen.SyncUpdateActive() {
178 p.screen.ForceSyncEnd()
179 }
180 p.mu.Unlock()
181 })
182 }
183 } else {
184 effects.syncEnded = effects.renderCoordinator != nil && effects.renderCoordinator.removeSyncEnd(p, p.syncGen, true)
185 }
186 }
187 effects.attention = p.ptyAttention
188 p.ptyAttention = false
189 effects.responses = append([]byte(nil), p.ptyResponses...)
190 p.ptyResponses = p.ptyResponses[:0]
191 effects.clipboards = append([]string(nil), p.ptyClipboards...)
192 p.ptyClipboards = p.ptyClipboards[:0]
193 p.mu.Unlock()
194 effects.syncCleanup.finish()
195 d.applyPanePTYEffects(p, effects)
196}
197
198// applyPanePTYEffects emits only effects still bound to the owner generation
199// that parsed them. Each owner-sensitive boundary revalidates independently so
200// ownership publication can retire the remaining effects without rerouting
201// bytes that belonged to the old owner.
202func (d *Daemon) applyPanePTYEffects(p *pane, effects panePTYEffects) {
203 owner := effects.lease.owner
204 if owner == nil || owner.session == nil || owner.tab == nil {
205 return
206 }
207 if effects.lease.Current() {
208 markSnapshotDirty(owner.session)
209 }
210 if effects.attention && effects.lease.Current() {
211 d.noteAttention(owner.session, owner.tab)
212 }
213 for _, b64 := range effects.clipboards {
214 if effects.lease.Current() {
215 d.forwardClipboardAsync(effects.lease, b64)
216 }
217 }
218 if len(effects.responses) > 0 && effects.lease.Current() {
219 if _, err := p.pty.Write(effects.responses); err != nil {
220 if sessionName, current := paneEffectSessionName(effects.lease); current {
221 d.log.Warn("pty response write failed", "err", err, "session", sessionName)
222 }
223 }
224 }
225 if effects.renderCoordinator == nil || !effects.lease.Current() {
226 return
227 }
228 renderable := d.paneRenderable(owner.session, owner.tab, p)
229 if !effects.lease.Current() {
230 return
231 }
232 if effects.wasSyncing != effects.isSyncing {
233 if effects.isSyncing {
234 if renderable {
235 effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
236 }
237 } else if effects.syncEnded && renderable {
238 effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
239 }
240 }
241 if effects.completeSyncRead && renderable {
242 effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
243 } else if renderable && !effects.isSyncing && effects.wasSyncing == effects.isSyncing {
244 effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
245 }
246}
247
248// paint draws the composed client frame (active tab plus status bar) and
249// sends the resulting bytes. The renderer shadow is reset on explicit invalidations
250// such as switch/create/close/rename/resize so the repaint is complete.
251func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid bool) {
252 if d != nil && d.runtimeObserver != nil {
253 d.runtimeObserver.ObserveRuntime(ports.NewRuntimeMark("daemon", kind, bytes, valid))
254 }
255}
256
257// runtimeMarkBatch captures immutable runtime marks during a render transaction.
258// paint owns attachment sendMu while it captures, composes, and emits; JSONL
259// observer I/O must therefore happen only after that ownership is released.
260type runtimeMarkBatch struct {
261 observer ports.RuntimeObserver
262 marks []ports.RuntimeMark
263 attachmentEffect *attachmentEffectTicket
264}
265
266func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
267 if d == nil {
268 return runtimeMarkBatch{}
269 }
270 return runtimeMarkBatch{observer: d.runtimeObserver}
271}
272
273func (b *runtimeMarkBatch) span(start, end ports.RuntimeMarkKind, bytes uint64) func(uint64, bool) {
274 if b == nil || b.observer == nil {
275 return func(uint64, bool) {}
276 }
277 correlation := ports.NewRuntimeCorrelation()
278 b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, start, bytes, true))
279 return func(endBytes uint64, valid bool) {
280 b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, end, endBytes, valid))
281 }
282}
283
284func (b *runtimeMarkBatch) flush() {
285 if b == nil || b.observer == nil {
286 return
287 }
288 for _, mark := range b.marks {
289 b.observer.ObserveRuntime(mark)
290 }
291}
292
293type paintResult uint8
294
295const (
296 paintRejected paintResult = iota
297 paintEmitted
298 paintBlockedCapacity
299)
300
301func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) paintResult {
302 // Session-owned PTY preparation remains local; attachment rendering is
303 // captured through session.captureRenderState.
304 sess := entry
305 local := sess != nil
306 marks := d.newRuntimeMarkBatch()
307 if lease != nil {
308 token := attachmentToken(entry, ac, ac.transport())
309 token.lease = lease
310 ticket, admitted := ac.beginAttachmentEffect(token)
311 if !admitted {
312 return paintRejected
313 }
314 marks.attachmentEffect = ticket
315 defer ticket.End()
316 }
317 var tb *tab
318 if local {
319 tb = sess.tabForAttachment(ac)
320 if tb == nil {
321 return paintRejected
322 }
323 }
324
325 ac.sendMu.Lock()
326 // sendMu is the attachment ownership boundary. Check the session's
327 // published identity while holding it so a deadline captured before an
328 // attach/replace cannot emit on either the old or new output chain.
329 entry.core().mu.Lock()
330 _, owned := entry.core().attachments[ac]
331 entry.core().mu.Unlock()
332 if !owned || ac.currentAttachmentSession() != entry {
333 ac.sendMu.Unlock()
334 return paintRejected
335 }
336 if lease != nil {
337 rc := attachmentRenderCoordinator(entry)
338 if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) {
339 ac.sendMu.Unlock()
340 return paintRejected
341 }
342 }
343 // Capacity is checked before any destructive capture. Refresh the atomic
344 // readiness snapshot while sendMu is held so direct test/setup mutations of
345 // the stream fields remain covered without making coordinator probes wait on
346 // a blocked transport send.
347 if ac.output != nil {
348 ac.output.syncCapacityLocked()
349 }
350 // The coordinator is the normal gate, but this ownership check also protects
351 // direct resize and test paint paths from consuming damage that cannot be
352 // emitted.
353 if ac.output != nil && ac.output.atCapacity() {
354 // The coordinator owns the blocked interval; this guard only protects
355 // direct test/resize paint calls from destructive capture.
356 ac.sendMu.Unlock()
357 return paintBlockedCapacity
358 }
359 // Composition owns attachment sendMu; initialize its lazy overlay state
360 // under that same ownership so concurrent fallback paints cannot observe a
361 // partially published runtime.
362 ac.initOverlays()
363 overlays := ac.overlays.SnapshotForRender()
364 repaintAttachedClients := false
365 defer func() {
366 overlays.Unlock()
367 // Every return below has released sendMu before reaching this defer.
368 // Emit the captured sequence before any follow-up repaint can introduce
369 // another boundary, while no attachment, session, tab, or pane lock is held.
370 marks.flush()
371 if repaintAttachedClients {
372 d.repaintAllAttachedClients()
373 }
374 }()
375 preview := snapshotPickerPreview(nil)
376 if local && overlays.previewTab != tb {
377 preview = snapshotPickerPreview(overlays.previewTab)
378 if overlays.previewTab == nil && overlays.remotePreview.Height > 0 {
379 preview = overlays.remotePreview
380 }
381 }
382 if local {
383 d.refreshSessionFocusedTitles(sess)
384 }
385 // Contextual ranks are completely captured under paletteMu. Compose them
386 // without reading the live MRU, whose order may have changed mid-interaction.
387 statusFeedback := overlays.statusFeedback
388 if statusFeedback == "" && overlays.resizeActive {
389 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"
390 }
391 bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRouteSnapshot)
392 applied := ac.getAppliedTheme()
393 bars.theme = applied.Raw
394 if local {
395 attentionVisible := pulseVisible(bars.attentionFrame)
396 repaintAttachedClients = sess.ackAttention(tb, ac, attentionVisible)
397 }
398
399 paletteCfg := d.currentPaletteConfig()
400 floatingCfg := d.currentFloatingConfig()
401 // Title refresh may inspect process state, so it remains before the capture
402 // boundary and outside tab/pane ownership locks.
403 if local {
404 tb.mu.Lock()
405 titleIDs := ac.renderScratch.titleIDs[:0]
406 if tb.tree != nil {
407 titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root)
408 }
409 ac.renderScratch.titleIDs = titleIDs
410 floating := tb.floating.pane
411 hasFloating := tb.floating.state == floatingVisible && floating != nil
412 tb.mu.Unlock()
413 for _, id := range titleIDs {
414 d.refreshPaneTitle(sess, id, tb)
415 }
416 if hasFloating {
417 d.refreshPaneDisplayTitle(sess, floating, false)
418 }
419 }
420
421 capturedOverlays := capturedOverlayRenderState{
422 copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
423 pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
424 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
425 }
426 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
427 state, ok := entry.captureRenderState(ac, renderCaptureRequest{
428 bars: bars,
429 overlays: capturedOverlays,
430 preview: preview,
431 floatingCfg: floatingCfg,
432 styles: applied.Resolved.Styles,
433 styleGeneration: applied.Generation,
434 reset: reset,
435 lease: lease,
436 })
437 endCapture(0, ok)
438 if !ok {
439 ac.sendMu.Unlock()
440 return paintRejected
441 }
442 if ac.renderStages.capture != nil {
443 ac.renderStages.capture()
444 }
445 if local && overlays.pickerActive && overlays.previewTab == tb {
446 previewState := *state
447 previewState.overlays = capturedOverlayRenderState{}
448 previewState.reset = true
449 state.preview = pickerPreviewFromCapturedRender(previewState)
450 }
451 captureOverlayLayers(state, overlays, paletteCfg)
452 endCompose := marks.span(ports.RuntimeComposeStart, ports.RuntimeComposeEnd, 0)
453 composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
454 endCompose(0, true)
455 if ac.renderStages.compose != nil {
456 ac.renderStages.compose()
457 }
458 d.emitFrame(entry, ac, state, composed, &marks)
459 return paintEmitted
460}
461
462var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
463
464func resolveStyles(styles []themeui.Styles) themeui.Styles {
465 if len(styles) > 0 {
466 return styles[0]
467 }
468 return fallbackChromeStyles
469}
470
471type barCache struct {
472 top []renderer.Cell
473 bottom []renderer.Cell
474}
475
476func (c *barCache) Reset() {
477 c.top = nil
478 c.bottom = nil
479}
480
481func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
482 pl.Content.X += dx
483 pl.Content.Y += dy
484 pl.TitleBar.X += dx
485 pl.TitleBar.Y += dy
486 return pl
487}
488
489func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) {
490 startX, startY := max(r.X, 0), max(r.Y, 0)
491 endX, endY := min(r.X+r.Width, dst.Width), min(r.Y+r.Height, dst.Height)
492 for y := startY; y < endY; y++ {
493 sy := y - r.Y
494 if sy < 0 || sy >= src.Height {
495 continue
496 }
497 for x := startX; x < endX; x++ {
498 sx := x - r.X
499 if sx < 0 || sx >= src.Width {
500 continue
501 }
502 cell := src.At(sx, sy)
503 if dim {
504 cell.Style = dimmer.Dim(cell.Style)
505 }
506 dst.Set(x, y, cell)
507 }
508 }
509}
510
511// drawDividers paints each precomputed divider gap onto frame, offsetting by
512// dy (composeFrame's content area starts one row below the top status bar).
513// The dividers themselves come from layout.SolveWithDividers, computed once
514// per capture alongside pane placements, rather than recomputed here.
515func drawDividers(frame renderer.Frame, dividers []layout.Divider, dy int, style renderer.Style) {
516 for _, d := range dividers {
517 glyph := rune('│')
518 if d.Dir == layout.Vertical {
519 glyph = '─'
520 }
521 y0 := max(d.Rect.Y+dy, 0)
522 y1 := min(d.Rect.Y+dy+d.Rect.Height, frame.Height)
523 x0 := max(d.Rect.X, 0)
524 x1 := min(d.Rect.X+d.Rect.Width, frame.Width)
525 for y := y0; y < y1; y++ {
526 for x := x0; x < x1; x++ {
527 frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
528 }
529 }
530 }
531}
532
533func (c *barCache) capture(top, bottom []renderer.Cell) {
534 c.top = cloneCells(c.top, top)
535 c.bottom = cloneCells(c.bottom, bottom)
536}
537
538func cloneCells(dst, src []renderer.Cell) []renderer.Cell {
539 if cap(dst) < len(src) {
540 dst = make([]renderer.Cell, len(src))
541 } else {
542 dst = dst[:len(src)]
543 }
544 copy(dst, src)
545 return dst
546}
547
548func sameCells(a, b []renderer.Cell) bool {
549 if len(a) != len(b) {
550 return false
551 }
552 for i := range a {
553 if !a[i].Equal(b[i]) {
554 return false
555 }
556 }
557 return true
558}
559
560func offsetDamage(in []renderer.Damage) []renderer.Damage {
561 return translateDamage(in, 0, 1)
562}
563
564func translateDamage(in []renderer.Damage, dx, dy int) []renderer.Damage {
565 out := make([]renderer.Damage, len(in))
566 for i, d := range in {
567 out[i] = d
568 if d.Kind != renderer.DamageFullRedraw {
569 out[i].X += dx
570 out[i].Y += dy
571 }
572 }
573 return out
574}
575
576func translatePaneDamage(d renderer.Damage, content domain.Rect, area domain.Rect) []renderer.Damage {
577 if d.Kind == renderer.DamageFullRedraw {
578 return []renderer.Damage{d}
579 }
580 if d.Kind == renderer.DamageScrollUp && (content.X != 0 || content.Width != area.Width) {
581 return []renderer.Damage{{Kind: renderer.DamageText, X: content.X, Y: content.Y, Width: content.Width, Height: content.Height}}
582 }
583 d.X += content.X
584 d.Y += content.Y
585 return []renderer.Damage{d}
586}