package daemon import ( "errors" "slices" renderer "github.com/bnema/vev-vt" "github.com/bnema/vev/internal/domain" "github.com/bnema/vev/internal/ports" "github.com/bnema/vev/internal/protocol" "github.com/bnema/vev/internal/usecase/layout" "github.com/bnema/vev/internal/usecase/notices" "github.com/bnema/vev/internal/usecase/palette" "github.com/bnema/vev/internal/usecase/prompt" themeui "github.com/bnema/vev/internal/usecase/theme" "github.com/bnema/vev/internal/usecase/ui" "github.com/bnema/vev/internal/usecase/visualsearch" ) // composeCacheInput is an attachment-local value snapshot. composeFrame never // retains or accesses the attachment that owns it. // renderStageHooks are optional observability hooks. capture and compose run // after their result is available, and emit after prepare plus transport success // (including a successful no-byte emission); those hooks run while sendMu is // held. Failed attempts count for capture/compose but never for emit. // handoffRebase marks the boundary immediately before a handoff acquires sendMu // to rebase attachment-owned output state, so it intentionally runs unlocked. type renderStageHooks struct { capture func() compose func() emit func() handoffRebase func() } type composeCacheInput struct { valid bool frame renderer.Frame layoutFingerprint string titleGenerations map[layout.PaneID]uint64 damage []renderer.Damage floatingVisible bool floatingFocused bool floatingGeneration uint64 floatingGeometry floatingGeometry floatingTitleGeneration uint64 bars barCache toastFootprints []domain.Rect theme themeui.Theme styleGeneration uint64 copyViewport copyViewportState } type composedRenderFrame struct { frame renderer.Frame damage []renderer.Damage cursor cursorOut cache composeCacheInput reset bool } // composeFrame is pure with respect to daemon ownership: it consumes only the // capture, the last committed cache, and an independent attachment-owned // scratch cache. It returns a new cache without mutating committed. const inactivePaneForegroundDimming = 55 // clearOutsideLayout blanks the content cells that the shared layout does not // cover. A window larger than the shared session geometry reuses its previous // frame, so without this the cells of a wider or taller earlier layout stay on // screen. func clearOutsideLayout(frame renderer.Frame, content, area domain.Rect) { blank := renderer.BlankCell() for y := content.Y; y < content.Y+content.Height; y++ { start := area.Width if y-content.Y >= area.Height { start = 0 } frame.FillRow(y, content.X+start, content.X+content.Width, blank) } } func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame { scratch := composeCacheInput{} if len(scratchIn) > 0 { scratch = scratchIn[0] } width, rows := state.layout.area.Width, state.layout.area.Height if state.window.Valid() { window := contentSize(state.window) width, rows = window.Cols, window.Rows } if width <= 0 || rows < 0 { return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset} } // Compose into the alternate attachment-owned buffer. The committed frame is // copied before incremental drawing so prepare/send failures cannot change it. contentY := 1 frameHeight := rows + tabChromeRows canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == frameHeight frame := scratch.frame if !canReuseFrame { frame = renderer.NewFrame(width, frameHeight) } if in.frame.Width == width && in.frame.Height == frameHeight { if state.overlays.copyMode != nil { // Compact Clone preserves page-local style IDs. Re-interning every // cell into scratch costs more than a structural copy while scrolling. // The clone still isolates committed state from failed publications. frame = in.frame.Clone() } else { for y := 0; y < frame.Height; y++ { for x := range frame.Width { frame.Set(x, y, in.frame.Cell(x, y)) } } } } styles := state.styles if styles == (themeui.Styles{}) { // Direct/test-only composition has no applied snapshot; production // capture always supplies immutable styles. styles = fallbackChromeStyles } state.styles = styles defaultDimmer := themeui.NewDimmer(state.theme) neutralBorder := styles.NeutralBorder inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) // The alternate cache owns these rows; the committed cache remains intact // until prepare/send succeeds. Row extraction would allocate on each paint. topBar := slices.Grow(scratch.bars.top[:0], width)[:width] drawTopBarSnapshot(topBar, state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) frame.WriteRow(0, 0, topBar) bottomBar := slices.Grow(scratch.bars.bottom[:0], width)[:width] drawStatusBarState(bottomBar, state.bars, styles) frame.WriteRow(rows+1, 0, bottomBar) content := domain.Rect{Y: contentY, Width: width, Height: rows} if state.layout.valid { drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder)) } 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 if full { clearOutsideLayout(frame, content, state.layout.area) } titles := scratch.titleGenerations if titles == nil { titles = make(map[layout.PaneID]uint64, len(state.panes)) } clear(titles) damage := scratch.damage[:0] for _, pane := range state.panes { pl := offsetPlacement(pane.placement, 0, contentY) if pl.TitleBar.Height > 0 { drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, neutralBorder, defaultDimmer) if !full && in.titleGenerations[pane.id] != pane.titleGeneration { damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height}) } titles[pane.id] = pane.titleGeneration } if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) { blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, inactivePaneDimmer) } for _, d := range pane.damage { if d.Kind != renderer.DamageFullRedraw { d.Y += contentY } damage = append(damage, d) } } // Keep the committed cache unadorned. Toasts are composed first so floating // terminals and scoped modals dim them with the rest of the complete frame. baseFrame := frame overlaysActive := state.overlays.active() toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0 var toastFootprints []domain.Rect if toastsVisible { frame = baseFrame.Clone() toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles) } if state.floating.visible { var floatingDamage []renderer.Damage frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{ baseFrame: frame, baseDamage: damage, floating: state.floating, content: content, theme: state.theme, borderMuted: styles.BorderMuted, borderActive: styles.BorderActive, cache: in, full: full || overlaysActive, }) damage = floatingDamage } copyOnly := state.overlays.copyMode != nil && state.overlays.copyActive && !state.overlays.copySearchActive && !state.overlays.paletteActive && !state.overlays.promptActive && !state.overlays.noticesOverlayActive && !state.overlays.resizeActive && !state.floating.visible var copyViewport copyViewportState var scrollDamage []renderer.Damage if copyOnly && !toastsVisible { copyViewport = captureCopyViewport(state, content) if !full { scrollDamage = copyViewportDamage(in.copyViewport, copyViewport, frame) } } if overlaysActive { if scrollDamage != nil && in.copyViewport.frame.Width == width && in.copyViewport.frame.Height == frameHeight { frame = composeScrolledCopyViewport(state, baseFrame, in.copyViewport, copyViewport, scrollDamage) } else { if !toastsVisible && !state.floating.visible { frame = baseFrame.Clone() } frame, damage = composeCapturedCopyMode(state, frame, damage, content) } frame, damage = composeCapturedOverlays(state, frame, damage) } for x := range width { topBar[x] = baseFrame.Cell(x, 0) bottomBar[x] = baseFrame.Cell(x, rows+1) } if !full { if !sameCells(in.bars.top, topBar) { damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) } if !sameCells(in.bars.bottom, bottomBar) { damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) } } // The cache stays toast-free. The terminal shadow, in contrast, includes // toasts, so every render damages both the last and current toast coverage. // This restores cells exposed by dismissal and redraws a stable toast over // any underlying pane update without promoting either case to a full frame. if full || overlaysActive { damage = []renderer.Damage{renderer.FullRedraw()} } else { damage = appendToastDamage(damage, in.toastFootprints) damage = appendToastDamage(damage, toastFootprints) } cursorInputs := state.cursor cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive cursor := desiredCapturedCursor(cursorInputs, contentY) // A plain copy viewport is painted on a clone, never into baseFrame. Keep // that unadorned base reusable between wheel events. Modal/floating paths // retain their conservative invalidation, and copy exit requests a reset. outCache := composeCacheInput{valid: !overlaysActive || copyOnly, 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} outCache.bars = barCache{top: topBar, bottom: bottomBar} if copyViewport.document != nil { copyViewport.frame = frame outCache.copyViewport = copyViewport } if scrollDamage != nil { damage = scrollDamage } // A copy viewport is ordinary terminal content, not a new output epoch. // Keep conservative resets for other overlays and explicit resynchronization. return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || (overlaysActive && !copyOnly)} } func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) { if pl.TitleBar.Y < 0 || pl.TitleBar.Y >= frame.Height { return } style := styles.StatusBar if !focused { style = dimmer.Dim(neutralBorder) } for x := max(pl.TitleBar.X, 0); x < min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width); x++ { frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) } ui.DrawText(frame, max(pl.TitleBar.X, 0), pl.TitleBar.Y, min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width), title, style) } func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut { if !c.renderable || c.hiddenByOverlay || !c.visible { return cursorOut{hidden: true} } style := c.style if !c.hasStyle { style = 1 } return cursorOut{valid: true, row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true} } func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) { if state == nil || snap == nil { return } o := &state.overlays o.copyActive, o.copySearchActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.paletteActive, snap.promptActive o.noticesOverlayActive = snap.noticesOverlayActive o.copyMode = snap.copyMode o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow if snap.copyPane != nil { o.copyPaneID = snap.copyPane.id } styles := state.styles if styles == (themeui.Styles{}) { styles = fallbackChromeStyles } rows := state.layout.area.Height rows += tabChromeRows size := domain.Size{Cols: state.layout.area.Width, Rows: rows} if snap.copySearchModel != nil { presentation := copySearchModal.Resolve(size) o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true} o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection}) } if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { presentation := noticesModal.Resolve(size) o.noticesOverlay = capturedModal{active: true, title: noticesModal.Title, presentation: presentation, focused: true} renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.PickerBase, Selection: styles.PickerSelection, Text: styles.PickerBase, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted} o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(presentation.Inner), renderStyles) } if snap.paletteActive && snap.paletteModel != nil { modal := paletteModalFor(paletteCfg) presentation := modal.Resolve(size) o.palette = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true} guidance := "" if snap.paletteHints != nil { guidance = snap.paletteHints.Feedback } o.paletteGuidance = snap.paletteFeedback 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}) } if snap.promptActive && snap.promptModel != nil { modal := promptModalFor(snap.promptModel.Title()) presentation := modal.Resolve(size) o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true} o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive}) } state.cursor.hiddenByOverlay = o.active() } func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) { o := state.overlays if !o.copyActive { return frame, damage } target := capturedCopyTarget(state, content) return composeCopyClientFrame(o.copyMode, target, frame, state.styles) } func capturedCopyTarget(state capturedRenderState, content domain.Rect) domain.Rect { o := state.overlays target := domain.Rect{} if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) { target = state.floating.geometry.translate(content.X, content.Y).Inner } if target.Width == 0 { id := o.copyPaneID if id == "" { id = state.layout.focus } for _, p := range state.panes { if p.id == id && !p.placement.Collapsed { target = p.placement.Content target.Y += content.Y break } } } return target } func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) { o := state.overlays // Paint in reverse keyboard priority so the same layer that owns input is // visually topmost: prompt > palette > notices > copy search. for _, modal := range []capturedModal{o.copySearch, o.noticesOverlay, o.palette, o.prompt} { if !modal.active { continue } applyOverlayBackdrop(frame, state.theme) border := state.styles.BorderMuted if modal.focused { border = state.styles.BorderActive } (ui.Modal{Title: modal.title}).CompositePresentation(frame, modal.presentation, border, state.styles.PickerBase) copyModalInner(frame, modal.presentation.Inner, modal.inner) damage = []renderer.Damage{renderer.FullRedraw()} } return frame, damage } // copyModalInner copies a captured model frame into its resolved destination. // Both source and destination are clipped so degenerate presentations remain // safe immutable composition inputs. func copyModalInner(dst renderer.Frame, target domain.Rect, src renderer.Frame) { left := max(target.X, 0) top := max(target.Y, 0) right := min(target.X+target.Width, dst.Width) bottom := min(target.Y+target.Height, dst.Height) if left >= right || top >= bottom { return } sourceX := left - target.X sourceY := top - target.Y width := min(right-left, src.Width-sourceX) height := min(bottom-top, src.Height-sourceY) if width <= 0 || height <= 0 { return } for y := range height { for x := range width { dst.Set(left+x, top+y, src.Cell(sourceX+x, sourceY+y)) } } } func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect { if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 { return nil } views := make([]ui.NoticeView, len(overlays.notices)) for i, n := range overlays.notices { views[i] = ui.NoticeView{Severity: n.Severity, Title: n.Code.String(), Message: n.Message, Count: n.Count} } return ui.ComposeNotices(frame, views, overlays.noticeOverflow, noticeStylesFrom(styles)) } func appendToastDamage(damage []renderer.Damage, footprints []domain.Rect) []renderer.Damage { for _, footprint := range footprints { if footprint.Width > 0 && footprint.Height > 0 { damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height}) } } return damage } // noticeStylesFrom picks the toast box color per severity from the theme's // chrome roles. Warn uses the dedicated BorderWarn role so it reads as // distinct from Info instead of sharing BorderMuted with it. func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles { return ui.NoticeStyles{ Text: styles.PickerBase, BoxError: styles.BorderActive, BoxWarn: styles.BorderWarn, BoxInfo: styles.BorderMuted, } } // commitDamageReceipts acknowledges only snapshots that reached the client. // sendMu serializes attachment transactions; panes are locked individually so // no pane lock is held while another is acquired. A stale generation is // intentionally acknowledged too: Screen conservatively leaves FullRedraw for // the next transaction. func commitDamageReceipts(receipts []damageReceipt) { for _, receipt := range receipts { if receipt.pane != nil { receipt.pane.mu.Lock() receipt.pane.screen.AcknowledgeDamage(receipt.generation) receipt.pane.mu.Unlock() continue } } } // frameFollowUp is the work a frame publication leaves for after the // attachment's sendMu is released: notices, repaints, and send-error cleanup // may re-enter rendering or take session locks, so they never run under it. type frameFollowUp struct { prepareErr error sendErr error sendTr ports.ServerConnection warnGraphics bool } // publishFrameLocked is the sole side-effecting half of the pipeline: it // revalidates ownership, prepares, sends, and commits or aborts one captured // frame. The caller holds ac.sendMu for the complete capture/compose/publish // transaction; publishFrameLocked never releases it. It reports false when the // frame was rejected before preparation. The returned follow-up must be passed // to settleFrame after sendMu is released. func (d *Daemon) publishFrameLocked(entry *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, marks *runtimeMarkBatch) (frameFollowUp, bool) { if entry == nil || entry.core() == nil || ac == nil || state == nil { return frameFollowUp{}, false } entry.core().mu.Lock() _, owned := entry.core().attachments[ac] position := protocol.RoutePosition{ Target: state.route.Target, ActiveTabID: state.view.tabID, } entry.core().mu.Unlock() if marks.attachmentEffect != nil { if !marks.attachmentEffect.current() || state.attachment != ac { return frameFollowUp{}, false } } else { if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry { return frameFollowUp{}, false } if state.lease != nil { rc := attachmentRenderCoordinator(entry) if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) { return frameFollowUp{}, false } } } endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0) prepared, err := ac.output.prepareFrame(d, state, composed.frame, composed.damage, composed.reset, composed.cursor) var data []byte if prepared != nil { data = prepared.data } endDiff(0, err == nil) if err != nil { return frameFollowUp{prepareErr: err}, true } var sendTr ports.ServerConnection var sendErr error rc := attachmentRenderCoordinator(entry) confirmUIFence := rc != nil && rc.needsUIFence(state.lease, state.uiFence) suppressedGraphics := state.suppressedGraphics && !ac.terminalCapabilities.SupportsKittyGraphics() if len(data) > 0 || prepared.ansi.context != nil { sendTransport := ac.transportSnapshot() sendTr = sendTransport.transport endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data))) if sendTr == nil { sendErr = errors.New("client transport is nil") } else { send := sendTr.SendOutput if sendTr.Capabilities().AsyncSend { send = sendTr.SendOutputAsync } interruptible := false if marks.attachmentEffect != nil { interruptible = marks.attachmentEffect.beginTransportSend(sendTransport) if !interruptible { sendErr = errAttachmentTransition } } if sendErr == nil { if len(data) > 0 { sendErr = prepared.send(ac.echoAck.Load(), send) } else { sendErr = prepared.publishNoBytes(confirmUIFence, func(update protocol.UIViewUpdate) error { if sendTr.Capabilities().AsyncSend { return sendTr.SendServerAsync(update) } return sendTr.SendServer(update) }) } } if marks.attachmentEffect != nil && interruptible { if sendErr != nil { marks.attachmentEffect.reportTransportFailure(sendTransport) } marks.attachmentEffect.endTransportSend() } } emitted := sendErr == nil && prepared.sent() endEmit(uint64(len(data)), emitted) if sendErr == nil && !emitted { return frameFollowUp{}, true } if sendErr != nil { prepared.abort() } } if sendErr == nil { if len(data) == 0 { prepared.commitNoSend() if !prepared.sent() { return frameFollowUp{}, true } } ac.afterFrame.emitted(state.frameCapture) // Publish only after output preparation and transport emission both // succeed. A cross-session transition may publish concurrently, but its // mandatory first-paint rebase waits for sendMu and therefore follows this // completed output transaction. ac.render.commitComposition(composed.cache) // A successful no-byte emission also commits: its renderer shadow still // represents the captured frame. Lock panes only under sendMu and with no // session guard held. commitDamageReceipts(state.receipts) if ac.renderStages.emit != nil { ac.renderStages.emit() } if position.ActiveTabID != "" && position != ac.output.lastRoutePosition { if positionValidateErr := position.Validate(); positionValidateErr != nil { d.log.Error("validate route position", "err", positionValidateErr) } else { sendTransport := ac.transportSnapshot() sendTr = sendTransport.transport interruptible := false if marks.attachmentEffect != nil { interruptible = marks.attachmentEffect.beginTransportSend(sendTransport) if !interruptible { sendErr = errAttachmentTransition } } if sendErr == nil { if sendTr == nil { sendErr = errors.New("client transport is nil") } else if sendTr.Capabilities().AsyncSend { sendErr = sendTr.SendServerAsync(position) } else { sendErr = sendTr.SendServer(position) } } if marks.attachmentEffect != nil && interruptible { if sendErr != nil { marks.attachmentEffect.reportTransportFailure(sendTransport) } marks.attachmentEffect.endTransportSend() } if sendErr == nil { ac.output.lastRoutePosition = position } } } if sendErr == nil && rc != nil && prepared.ansi.boundary.ViewPublication != 0 && (marks.attachmentEffect == nil || marks.attachmentEffect.current()) { if pending := rc.retireUIFence(state.lease, state.uiFence); pending != nil { receipt := prepared.ansi.boundary receipt.ActionID = pending.actionID sendTransport := ac.transportSnapshot() sendTr = sendTransport.transport sendErr = sendUIReceiptLocked(marks.attachmentEffect, sendTransport, receipt) if sendErr == nil { rc.finishUIFence(state.lease, pending) } else { rc.failUIFence(state.lease, pending) } } } } return frameFollowUp{sendErr: sendErr, sendTr: sendTr, warnGraphics: suppressedGraphics}, true } // settleFrame runs a publication's follow-up with sendMu released and no // attachment, session, tab, or pane lock held. func (d *Daemon) settleFrame(entry *session, ac *attachedClient, marks *runtimeMarkBatch, f frameFollowUp) { if f.prepareErr != nil { core := entry.core() core.mu.Lock() sessionName := core.name core.mu.Unlock() d.log.Error("render draw failed", "err", f.prepareErr, "session", sessionName) // Without a coordinator reportError repaints synchronously. Suppress only // that nested notice repaint; leave the guard before returning so a later, // independent failed transaction can still notify the user. if entry.renderCoordinator() == nil { if !ac.prepareFailureFallback.CompareAndSwap(false, true) { return } d.reportError(entry, domain.UserErr(domain.NoticeInternal, "display update failed", f.prepareErr)) ac.prepareFailureFallback.Store(false) return } d.reportError(entry, domain.UserErr(domain.NoticeInternal, "display update failed", f.prepareErr)) // Notices are session-scoped, while the attachment repaint still needs // to redraw its chrome after a failed transaction. d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed") return } if f.warnGraphics && d.warnUnsupportedGraphics(ac) { d.repaintForNotice(ac) } if f.sendErr != nil { // A transport failure may invalidate the role gate. Release this render's // admission first. Detachment freezes the gate and therefore cannot mutate // ownership until any enclosing admitted operation has also ended. if marks.attachmentEffect == nil { d.detachOnSendError(entry, ac, f.sendTr) } else { // Capture the exact admitted capability, including its coordinator // lease, before End permits a new attachment publication. Reserve // cleanup accounting before End so terminal Wait cannot race a later Add. capability := marks.attachmentEffect.capability() launchCleanup := d.reserveAttachmentSendErrorCleanup(capability, f.sendTr) marks.attachmentEffect.End() launchCleanup() } } }