1package daemon
2
3import (
4 "errors"
5 "strconv"
6
7 renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/layout"
11 "github.com/bnema/vev/internal/usecase/notices"
12 "github.com/bnema/vev/internal/usecase/palette"
13 "github.com/bnema/vev/internal/usecase/picker"
14 "github.com/bnema/vev/internal/usecase/prompt"
15 themeui "github.com/bnema/vev/internal/usecase/theme"
16 "github.com/bnema/vev/internal/usecase/ui"
17 "github.com/bnema/vev/internal/usecase/visualsearch"
18)
19
20// composeCacheInput is an attachment-local value snapshot. composeFrame never
21// retains or accesses the attachment that owns it.
22// renderStageHooks are optional observability hooks. capture and compose run
23// after their result is available, and emit after prepare plus transport success
24// (including a successful no-byte emission); those hooks run while sendMu is
25// held. Failed attempts count for capture/compose but never for emit.
26// handoffRebase marks the boundary immediately before a handoff acquires sendMu
27// to rebase attachment-owned output state, so it intentionally runs unlocked.
28type renderStageHooks struct {
29 capture func()
30 compose func()
31 emit func()
32 handoffRebase func()
33}
34
35type composeCacheInput struct {
36 valid bool
37 frame renderer.Frame
38 layoutFingerprint string
39 titleGenerations map[layout.PaneID]uint64
40 damage []renderer.Damage
41 floatingVisible bool
42 floatingFocused bool
43 floatingGeneration uint64
44 floatingGeometry floatingGeometry
45 floatingTitleGeneration uint64
46 bars barCache
47 toastFootprints []domain.Rect
48 theme themeui.Theme
49 styleGeneration uint64
50}
51
52type composedRenderFrame struct {
53 frame renderer.Frame
54 damage []renderer.Damage
55 cursor cursorOut
56 cache composeCacheInput
57 reset bool
58}
59
60// composeFrame is pure with respect to daemon ownership: it consumes only the
61// capture, the last committed cache, and an independent attachment-owned
62// scratch cache. It returns a new cache without mutating committed.
63const inactivePaneForegroundDimming = 55
64
65func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {
66 scratch := composeCacheInput{}
67 if len(scratchIn) > 0 {
68 scratch = scratchIn[0]
69 }
70 width, rows := state.layout.area.Width, state.layout.area.Height
71 if state.window.Valid() {
72 window := contentSize(state.window)
73 width, rows = window.Cols, window.Rows
74 }
75 if width <= 0 || rows < 0 {
76 return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}
77 }
78 // Compose into the alternate attachment-owned buffer. The committed frame is
79 // copied before incremental drawing so prepare/send failures cannot change it.
80 contentY := 1
81 frameHeight := rows + tabChromeRows
82 canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == frameHeight
83 frame := scratch.frame
84 if !canReuseFrame {
85 frame = renderer.NewFrame(width, frameHeight)
86 }
87 if in.frame.Width == width && in.frame.Height == frameHeight {
88 for y := 0; y < frame.Height; y++ {
89 copy(frame.Row(y), in.frame.Row(y))
90 }
91 }
92 styles := state.styles
93 if styles == (themeui.Styles{}) {
94 // Direct/test-only composition has no applied snapshot; production
95 // capture always supplies immutable styles.
96 styles = fallbackChromeStyles
97 }
98 state.styles = styles
99 defaultDimmer := themeui.NewDimmer(state.theme)
100 neutralBorder := styles.NeutralBorder
101 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
102 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
103 drawStatusBarState(frame.Row(rows+1), state.bars, styles)
104 content := domain.Rect{Y: contentY, Width: width, Height: rows}
105 if state.layout.valid {
106 drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder))
107 }
108
109 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != frameHeight || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible
110 titles := scratch.titleGenerations
111 if titles == nil {
112 titles = make(map[layout.PaneID]uint64, len(state.panes))
113 }
114 clear(titles)
115 damage := scratch.damage[:0]
116 for _, pane := range state.panes {
117 pl := offsetPlacement(pane.placement, 0, contentY)
118 if pl.TitleBar.Height > 0 {
119 drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, neutralBorder, defaultDimmer)
120 if !full && in.titleGenerations[pane.id] != pane.titleGeneration {
121 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})
122 }
123 titles[pane.id] = pane.titleGeneration
124 }
125 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
126 blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, inactivePaneDimmer)
127 }
128 for _, d := range pane.damage {
129 if d.Kind != renderer.DamageFullRedraw {
130 d.Y += contentY
131 }
132 damage = append(damage, d)
133 }
134 }
135 // Keep the committed cache unadorned. Toasts are composed first so floating
136 // terminals and scoped modals dim them with the rest of the complete frame.
137 baseFrame := frame
138 overlaysActive := state.overlays.active()
139 toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0
140 var toastFootprints []domain.Rect
141 if toastsVisible {
142 frame = baseFrame.Clone()
143 toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles)
144 }
145 if state.floating.visible {
146 var floatingDamage []renderer.Damage
147 frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{
148 baseFrame: frame,
149 baseDamage: damage,
150 floating: state.floating,
151 content: content,
152 theme: state.theme,
153 borderMuted: styles.BorderMuted,
154 borderActive: styles.BorderActive,
155 cache: in,
156 full: full || overlaysActive,
157 })
158 damage = floatingDamage
159 }
160 if overlaysActive {
161 if !toastsVisible && !state.floating.visible {
162 frame = baseFrame.Clone()
163 }
164 frame, damage = composeCapturedCopyMode(state, frame, damage, content)
165 frame, damage = composeCapturedOverlays(state, frame, damage)
166 }
167 if !full {
168 if !sameCells(in.bars.top, baseFrame.Row(0)) {
169 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
170 }
171 if !sameCells(in.bars.bottom, baseFrame.Row(rows+1)) {
172 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
173 }
174 }
175 // The cache stays toast-free. The terminal shadow, in contrast, includes
176 // toasts, so every render damages both the last and current toast coverage.
177 // This restores cells exposed by dismissal and redraws a stable toast over
178 // any underlying pane update without promoting either case to a full frame.
179 if full || overlaysActive {
180 damage = []renderer.Damage{renderer.FullRedraw()}
181 } else {
182 damage = appendToastDamage(damage, in.toastFootprints)
183 damage = appendToastDamage(damage, toastFootprints)
184 }
185 cursorInputs := state.cursor
186 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
187 cursor := desiredCapturedCursor(cursorInputs, contentY)
188 outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
189 outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
190 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
191}
192
193func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) {
194 if pl.TitleBar.Y < 0 || pl.TitleBar.Y >= frame.Height {
195 return
196 }
197 style := styles.StatusBar
198 if !focused {
199 style = dimmer.Dim(neutralBorder)
200 }
201 for x := max(pl.TitleBar.X, 0); x < min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width); x++ {
202 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
203 }
204 ui.DrawText(frame, max(pl.TitleBar.X, 0), pl.TitleBar.Y, min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width), title, style)
205}
206
207func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
208 if !c.renderable || c.hiddenByOverlay || !c.visible {
209 return cursorOut{hidden: true}
210 }
211 style := c.style
212 if !c.hasStyle {
213 style = 1
214 }
215 return cursorOut{valid: true, row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true}
216}
217
218func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) {
219 if state == nil || snap == nil {
220 return
221 }
222 o := &state.overlays
223 o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive
224 o.noticesOverlayActive = snap.noticesOverlayActive
225 o.copyMode = snap.copyMode
226 o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow
227 if snap.copyPane != nil {
228 o.copyPaneID = snap.copyPane.id
229 }
230 styles := state.styles
231 if styles == (themeui.Styles{}) {
232 styles = fallbackChromeStyles
233 }
234 rows := state.layout.area.Height
235 rows += tabChromeRows
236 size := domain.Size{Cols: state.layout.area.Width, Rows: rows}
237 if snap.copySearchModel != nil {
238 presentation := copySearchModal.Resolve(size)
239 o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}
240 o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
241 }
242 if snap.pickerActive && snap.pickerModel != nil {
243 presentation := pickerModal.Resolve(size)
244 title := snap.pickerTitle
245 if title == "" {
246 title = pickerModal.Title
247 }
248 o.picker = capturedModal{active: true, title: title, presentation: presentation, focused: true}
249 stoppedStyle := styles.PickerName
250 stoppedStyle.Attrs |= renderer.AttrDim
251 stoppedStyle.Italic = true
252 renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator, Stopped: stoppedStyle}
253 o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles)
254 }
255 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
256 presentation := noticesModal.Resolve(size)
257 o.noticesOverlay = capturedModal{active: true, title: noticesModal.Title, presentation: presentation, focused: true}
258 renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.PickerBase, Selection: styles.PickerSelection, Text: styles.PickerBase, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted}
259 o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(presentation.Inner), renderStyles)
260 }
261 if snap.paletteActive && snap.paletteModel != nil {
262 modal := paletteModalFor(size, paletteCfg)
263 presentation := modal.Resolve(size)
264 o.palette = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
265 guidance := ""
266 if snap.paletteHints != nil {
267 guidance = snap.paletteHints.Feedback
268 }
269 o.paletteGuidance = snap.paletteFeedback
270 o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Preview: snap.palettePreview, Feedback: snap.paletteFeedback})
271 }
272 if snap.promptActive && snap.promptModel != nil {
273 modal := promptModalFor(snap.promptModel.Title())
274 presentation := modal.Resolve(size)
275 o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
276 o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})
277 }
278 state.cursor.hiddenByOverlay = o.active()
279}
280
281func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) {
282 o := state.overlays
283 if !o.copyActive {
284 return frame, damage
285 }
286 target := domain.Rect{}
287 if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) {
288 target = state.floating.geometry.translate(content.X, content.Y).Inner
289 }
290 if target.Width == 0 {
291 id := o.copyPaneID
292 if id == "" {
293 id = state.layout.focus
294 }
295 for _, p := range state.panes {
296 if p.id == id && !p.placement.Collapsed {
297 target = p.placement.Content
298 target.Y += content.Y
299 break
300 }
301 }
302 }
303 return composeCopyClientFrame(o.copyMode, target, frame, state.styles)
304}
305
306func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) {
307 o := state.overlays
308 // Paint in reverse keyboard priority so the same layer that owns input is
309 // visually topmost: prompt > palette > picker > notices > copy search.
310 for _, modal := range []capturedModal{o.copySearch, o.noticesOverlay, o.picker, o.palette, o.prompt} {
311 if !modal.active {
312 continue
313 }
314 applyOverlayBackdrop(frame, state.theme)
315 border := state.styles.BorderMuted
316 if modal.focused {
317 border = state.styles.BorderActive
318 }
319 (ui.Modal{Title: modal.title}).CompositePresentation(frame, modal.presentation, border, state.styles.PickerBase)
320 copyModalInner(frame, modal.presentation.Inner, modal.inner)
321 damage = []renderer.Damage{renderer.FullRedraw()}
322 }
323 return frame, damage
324}
325
326// copyModalInner copies a captured model frame into its resolved destination.
327// Both source and destination are clipped so degenerate presentations remain
328// safe immutable composition inputs.
329func copyModalInner(dst renderer.Frame, target domain.Rect, src renderer.Frame) {
330 left := max(target.X, 0)
331 top := max(target.Y, 0)
332 right := min(target.X+target.Width, dst.Width)
333 bottom := min(target.Y+target.Height, dst.Height)
334 if left >= right || top >= bottom {
335 return
336 }
337
338 sourceX := left - target.X
339 sourceY := top - target.Y
340 width := min(right-left, src.Width-sourceX)
341 height := min(bottom-top, src.Height-sourceY)
342 if width <= 0 || height <= 0 {
343 return
344 }
345 for y := range height {
346 copy(dst.Row(top + y)[left:left+width], src.Row(sourceY + y)[sourceX:sourceX+width])
347 }
348}
349
350func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect {
351 if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 {
352 return nil
353 }
354 views := make([]ui.NoticeView, len(overlays.notices))
355 for i, n := range overlays.notices {
356 views[i] = ui.NoticeView{Severity: n.Severity, Title: n.Code.String(), Message: n.Message, Count: n.Count}
357 }
358 return ui.ComposeNotices(frame, views, overlays.noticeOverflow, noticeStylesFrom(styles))
359}
360
361func appendToastDamage(damage []renderer.Damage, footprints []domain.Rect) []renderer.Damage {
362 for _, footprint := range footprints {
363 if footprint.Width > 0 && footprint.Height > 0 {
364 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height})
365 }
366 }
367 return damage
368}
369
370// noticeStylesFrom picks the toast box color per severity from the theme's
371// chrome roles. Warn uses the dedicated BorderWarn role so it reads as
372// distinct from Info instead of sharing BorderMuted with it.
373func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles {
374 return ui.NoticeStyles{
375 Text: styles.PickerBase,
376 BoxError: styles.BorderActive,
377 BoxWarn: styles.BorderWarn,
378 BoxInfo: styles.BorderMuted,
379 }
380}
381
382type cursorCandidate struct {
383 data []byte
384 next cursorOut
385}
386
387func (ac *attachedClient) prepareCursorTail(desired cursorOut, force bool) cursorCandidate {
388 candidate := cursorCandidate{next: desired}
389 candidate.next.valid = true
390 prev := ac.lastCursor
391 changed := force || !prev.valid || prev.hidden != desired.hidden || prev.row != desired.row || prev.col != desired.col || prev.style != desired.style || prev.hasStyle != desired.hasStyle
392 if !changed {
393 return candidate
394 }
395 if desired.hidden {
396 candidate.data = []byte("\x1b[?25l")
397 return candidate
398 }
399 candidate.data = append(candidate.data, "\x1b["...)
400 candidate.data = strconv.AppendInt(candidate.data, int64(desired.row+1), 10)
401 candidate.data = append(candidate.data, ';')
402 candidate.data = strconv.AppendInt(candidate.data, int64(desired.col+1), 10)
403 candidate.data = append(candidate.data, 'H')
404 if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
405 candidate.data = append(candidate.data, "\x1b["...)
406 candidate.data = strconv.AppendInt(candidate.data, int64(desired.style), 10)
407 candidate.data = append(candidate.data, " q"...)
408 }
409 candidate.data = append(candidate.data, "\x1b[?25h"...)
410 return candidate
411}
412
413// commitDamageReceipts acknowledges only snapshots that reached the client.
414// sendMu serializes attachment transactions; panes are locked individually so
415// no pane lock is held while another is acquired. A stale generation is
416// intentionally acknowledged too: Screen conservatively leaves FullRedraw for
417// the next transaction.
418func commitDamageReceipts(receipts []damageReceipt) {
419 for _, receipt := range receipts {
420 if receipt.pane != nil {
421 receipt.pane.mu.Lock()
422 receipt.pane.screen.AcknowledgeDamage(receipt.generation)
423 receipt.pane.mu.Unlock()
424 continue
425 }
426 }
427}
428
429// emitFrame is the sole side-effecting half of the pipeline. The caller holds
430// sendMu for the complete capture/compose/emit transaction.
431func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, batches ...*runtimeMarkBatch) bool {
432 var ownedMarks runtimeMarkBatch
433 var marks *runtimeMarkBatch
434 if len(batches) != 0 {
435 marks = batches[0]
436 } else {
437 ownedMarks = d.newRuntimeMarkBatch()
438 marks = &ownedMarks
439 // Direct test callers retain the same guarantee as paint: observer I/O
440 // follows emitFrame's release of attachment ownership.
441 defer marks.flush()
442 }
443 if entry == nil || entry.core() == nil || ac == nil || state == nil {
444 if ac != nil {
445 ac.sendMu.Unlock()
446 }
447 return false
448 }
449 entry.core().mu.Lock()
450 _, owned := entry.core().attachments[ac]
451 position := ports.RoutePosition{
452 Target: ports.ExactSessionTarget{
453 LifecycleID: entry.core().incarnation,
454 SessionName: entry.core().name,
455 },
456 ActiveTabID: state.view.tabID,
457 }
458 entry.core().mu.Unlock()
459 if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
460 ac.sendMu.Unlock()
461 return false
462 }
463 if state.lease != nil {
464 rc := attachmentRenderCoordinator(entry)
465 if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
466 ac.sendMu.Unlock()
467 return false
468 }
469 }
470 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
471 var (
472 preparedANSI *preparedOutput
473 err error
474 cursor cursorCandidate
475 data []byte
476 )
477 preparedANSI, err = ac.output.prepare(composed.frame, composed.damage, composed.reset)
478 if err == nil {
479 cursor = ac.prepareCursorTail(composed.cursor, len(preparedANSI.data) > 0)
480 data = append([]byte(nil), preparedANSI.data...)
481 data = append(data, cursor.data...)
482 }
483 endDiff(0, err == nil)
484 if err != nil {
485 ac.sendMu.Unlock()
486 core := entry.core()
487 core.mu.Lock()
488 sessionName := core.name
489 core.mu.Unlock()
490 d.log.Error("render draw failed", "err", err, "session", sessionName)
491 // Without a coordinator reportError repaints synchronously. Suppress only
492 // that nested notice repaint; leave the guard before returning so a later,
493 // independent failed transaction can still notify the user.
494 if entry.renderCoordinator() == nil {
495 if !ac.prepareFailureFallback.CompareAndSwap(false, true) {
496 return true
497 }
498 d.reportError(entry, domain.UserErr(domain.NoticeInternal, "display update failed", err))
499 ac.prepareFailureFallback.Store(false)
500 return true
501 }
502 d.reportError(entry, domain.UserErr(domain.NoticeInternal, "display update failed", err))
503 // Notices are session-scoped, while the attachment repaint still needs
504 // to redraw its chrome after a failed transaction.
505 d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
506 return true
507 }
508 var sendTr ports.Transport
509 var sendErr error
510 if len(data) > 0 {
511 sendTransport := ac.transportSnapshot()
512 sendTr = sendTransport.transport
513 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
514 if sendTr == nil {
515 sendErr = errors.New("client transport is nil")
516 } else {
517 send := sendTr.Send
518 if async, ok := sendTr.(ports.AsyncTransport); ok {
519 send = async.SendAsync
520 }
521 interruptible := false
522 if marks.attachmentEffect != nil {
523 interruptible = marks.attachmentEffect.beginTransportSend(sendTransport)
524 if !interruptible {
525 sendErr = errAttachmentTransition
526 }
527 }
528 if sendErr == nil {
529 sendErr = preparedANSI.send(data, ac.echoAck.Load(), send)
530 }
531 if marks.attachmentEffect != nil && interruptible {
532 if sendErr != nil {
533 marks.attachmentEffect.reportTransportFailure(sendTransport)
534 }
535 marks.attachmentEffect.endTransportSend()
536 }
537 }
538 emitted := sendErr == nil && preparedANSI.sent
539 endEmit(uint64(len(data)), emitted)
540 if sendErr == nil && !emitted {
541 ac.sendMu.Unlock()
542 return true
543 }
544 }
545 if sendErr == nil {
546 if len(data) == 0 {
547 preparedANSI.commitNoSend()
548 if !preparedANSI.sent {
549 ac.sendMu.Unlock()
550 return true
551 }
552 }
553 // Publish only after output preparation and transport emission both
554 // succeed. A cross-session transition may publish concurrently, but its
555 // mandatory first-paint rebase waits for sendMu and therefore follows this
556 // completed output transaction.
557 ac.lastCursor = cursor.next
558 ac.pipelineScratch = ac.pipelineCache
559 ac.pipelineCache = composed.cache
560
561 // A successful no-byte emission also commits: its renderer shadow still
562 // represents the captured frame. Lock panes only under sendMu and with no
563 // session guard held.
564 commitDamageReceipts(state.receipts)
565 if ac.renderStages.emit != nil {
566 ac.renderStages.emit()
567 }
568
569 if position.ActiveTabID != "" && position != ac.lastRoutePosition {
570 payload, positionMarshalErr := ports.MarshalRoutePosition(position)
571 if positionMarshalErr != nil {
572 d.log.Error("marshal route position", "err", positionMarshalErr)
573 } else {
574 sendTransport := ac.transportSnapshot()
575 sendTr = sendTransport.transport
576 interruptible := false
577 if marks.attachmentEffect != nil {
578 interruptible = marks.attachmentEffect.beginTransportSend(sendTransport)
579 if !interruptible {
580 sendErr = errAttachmentTransition
581 }
582 }
583 if sendErr == nil {
584 if sendTr == nil {
585 sendErr = errors.New("client transport is nil")
586 } else {
587 send := sendTr.Send
588 if async, ok := sendTr.(ports.AsyncTransport); ok {
589 send = async.SendAsync
590 }
591 sendErr = send(ports.Frame{Type: ports.MsgRoutePosition, Payload: payload})
592 }
593 }
594 if marks.attachmentEffect != nil && interruptible {
595 if sendErr != nil {
596 marks.attachmentEffect.reportTransportFailure(sendTransport)
597 }
598 marks.attachmentEffect.endTransportSend()
599 }
600 if sendErr == nil {
601 ac.lastRoutePosition = position
602 }
603 }
604 }
605 }
606 ac.sendMu.Unlock()
607 if sendErr != nil {
608 // A transport failure may invalidate the role gate. Release this render's
609 // admission first. Detachment freezes the gate and therefore cannot mutate
610 // ownership until any enclosing admitted operation has also ended.
611 if marks.attachmentEffect == nil {
612 d.detachOnSendError(entry, ac, sendTr)
613 } else {
614 // Capture the exact admitted capability, including its coordinator
615 // lease, before End permits a new attachment publication. Reserve
616 // cleanup accounting before End so terminal Wait cannot race a later Add.
617 token := marks.attachmentEffect.connectionToken()
618 launchCleanup := d.reserveAttachmentSendErrorCleanup(token, sendTr)
619 marks.attachmentEffect.End()
620 launchCleanup()
621 }
622 }
623 return true
624}