1package daemon
2
3import (
4 "bytes"
5 "cmp"
6 "slices"
7
8 vt "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"
10)
11
12// attachmentView is the mutable viewport owned by one attachment. Tab and
13// pane identity is stable across tab insertion, deletion, and reordering;
14// neither field is a session-local index or layout PaneID.
15type attachmentView struct {
16 tabID domain.TabStableID
17 paneID domain.PaneStableID
18 windowTop int
19 windowRows int
20 windowSet bool
21 bookmark vt.RowID
22 liveBottom bool
23 revision uint64
24}
25
26func (ac *attachedClient) viewSnapshot() attachmentView {
27 if ac == nil {
28 return attachmentView{}
29 }
30 ac.viewMu.Lock()
31 defer ac.viewMu.Unlock()
32 return ac.view
33}
34
35func (ac *attachedClient) publishViewLocked(view attachmentView) {
36 // Rebase while viewMu is held, before the new revision becomes visible.
37 // Output fences take the same lock, so no side effect can pair the new
38 // ViewRevision with the previous output epoch. Test/headless fixtures may
39 // leave output.attachment unset; use the stream-local lock for those.
40 if ac.output != nil && ac.view.revision != view.revision {
41 if ac.output.attachment == ac {
42 ac.output.rebaseLocked()
43 } else {
44 ac.output.stateMu.Lock()
45 ac.output.rebaseLocked()
46 ac.output.stateMu.Unlock()
47 }
48 }
49 ac.view = view
50}
51
52func (ac *attachedClient) publishView(view attachmentView) {
53 if ac == nil {
54 return
55 }
56 ac.viewMu.Lock()
57 defer ac.viewMu.Unlock()
58 ac.publishViewLocked(view)
59}
60
61func (ac *attachedClient) publishViewIfCurrent(before, view attachmentView) bool {
62 if ac == nil {
63 return false
64 }
65 ac.viewMu.Lock()
66 defer ac.viewMu.Unlock()
67 if ac.view != before {
68 return false
69 }
70 ac.publishViewLocked(view)
71 return true
72}
73
74// registerAttachmentLocked admits ac exactly once. Caller holds s.mu and must
75// have acquired dispatchMu first when the operation came from a client.
76func (s *session) registerAttachmentLocked(ac *attachedClient) bool {
77 if s == nil || !s.sessionCore.registerAttachmentLocked(ac) {
78 return false
79 }
80 view := ac.viewSnapshot()
81 if view.tabID == "" || view.paneID == "" {
82 view = s.repairAttachmentViewLocked(ac, view)
83 ac.publishView(view)
84 }
85 return true
86}
87
88func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool {
89 if c == nil || ac == nil {
90 return false
91 }
92 if c.attachments == nil {
93 c.attachments = make(map[*attachedClient]struct{})
94 }
95 if _, exists := c.attachments[ac]; exists {
96 return false
97 }
98 if c.attachmentOrder == nil {
99 c.attachmentOrder = make(map[*attachedClient]uint64)
100 }
101 c.nextAttachmentID++
102 c.attachments[ac] = struct{}{}
103 c.attachmentOrder[ac] = c.nextAttachmentID
104 if c.claimGeometryOwnerLocked(ac, ac.sizeSnapshot()) == 0 {
105 c.refreshGeometryOwnerLocked()
106 }
107 return true
108}
109
110// latestValidGeometryOwnerLocked selects the most recent valid attachment,
111// excluding one claimant when a rejected request is being released. Caller
112// holds the owning session's mu.
113func (c *sessionCore) latestValidGeometryOwnerLocked(exclude *attachedClient) *attachedClient {
114 var owner *attachedClient
115 var ownerClaim, ownerOrder uint64
116 for candidate := range c.attachments {
117 if candidate == nil || candidate == exclude || !candidate.sizeSnapshot().Valid() || !candidate.geometryClaimSizeSnapshot().Valid() {
118 continue
119 }
120 claim := candidate.geometryClaim.Load()
121 order := c.attachmentOrder[candidate]
122 if owner == nil || claim > ownerClaim || (claim == ownerClaim && order > ownerOrder) {
123 owner = candidate
124 ownerClaim = claim
125 ownerOrder = order
126 }
127 }
128 return owner
129}
130
131// refreshGeometryOwnerLocked repairs a removed or invalid owner without
132// inventing a newer claim. Caller holds the owning session's mu.
133func (c *sessionCore) refreshGeometryOwnerLocked() {
134 if c == nil {
135 return
136 }
137 owner := c.geometryOwner.Load()
138 if owner != nil {
139 if _, ok := c.attachments[owner]; ok && owner.sizeSnapshot().Valid() && owner.geometryClaimSizeSnapshot().Valid() {
140 return
141 }
142 }
143 c.geometryMu.Lock()
144 c.geometryOwner.Store(c.latestValidGeometryOwnerLocked(nil))
145 c.geometryMu.Unlock()
146}
147
148// claimGeometryOwnerLocked records the latest attachment claim. Caller holds
149// the owning session's mu; the atomic publication lets resize transactions
150// validate the claim while tab locks are held.
151func (c *sessionCore) claimGeometryOwnerLocked(ac *attachedClient, size domain.Size) uint64 {
152 if c == nil || ac == nil || !size.Valid() {
153 return 0
154 }
155 c.geometryMu.Lock()
156 defer c.geometryMu.Unlock()
157 c.geometryClaimSeq++
158 claim := c.geometryClaimSeq
159 ac.publishGeometryClaim(size, claim)
160 c.geometryOwner.Store(ac)
161 return claim
162}
163
164func registerAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
165 if entry == nil || entry.core() == nil {
166 return false
167 }
168 // Transition publication may already hold tab locks (move commit). Only
169 // publish collection membership here; target repair runs at the next view
170 // snapshot outside that critical section.
171 return entry.core().registerAttachmentLocked(ac)
172}
173
174func (s *session) registerAttachment(ac *attachedClient) bool {
175 if s == nil || ac == nil {
176 return false
177 }
178 s.dispatchMu.Lock()
179 defer s.dispatchMu.Unlock()
180 s.mu.Lock()
181 defer s.mu.Unlock()
182 return s.registerAttachmentLocked(ac)
183}
184
185// claimGeometryOwner records an explicit resize claim after the attachment
186// has already published its new local size. It is also used by callers that
187// bypass the wire resize path.
188func (s *session) claimGeometryOwner(ac *attachedClient) (uint64, bool) {
189 if ac == nil {
190 return 0, false
191 }
192 return s.claimGeometryOwnerForSize(ac, ac.sizeSnapshot())
193}
194
195// claimGeometryOwnerForSize records a requested shared geometry independently
196// of the attachment-local publication. Transactional resize commits use this
197// before the PTY phase so a stale request cannot publish after a newer claim.
198func (s *session) claimGeometryOwnerForSize(ac *attachedClient, size domain.Size) (uint64, bool) {
199 if s == nil || ac == nil || !size.Valid() {
200 return 0, false
201 }
202 s.mu.Lock()
203 defer s.mu.Unlock()
204 if _, ok := s.attachments[ac]; !ok {
205 return 0, false
206 }
207 return s.claimGeometryOwnerLocked(ac, size), true
208}
209
210// releaseGeometryClaim drops a claim that never reached the coordinator's
211// resize request. It never creates a new claim; an older valid peer may become
212// the source again, or the session retains its headless geometry.
213func (s *session) releaseGeometryClaim(ac *attachedClient, claim uint64) {
214 if s == nil || ac == nil || claim == 0 {
215 return
216 }
217 s.mu.Lock()
218 defer s.mu.Unlock()
219 if s.geometryOwner.Load() != ac || ac.geometryClaim.Load() != claim {
220 return
221 }
222 ac.clearGeometryClaim()
223 s.geometryMu.Lock()
224 s.geometryOwner.Store(s.latestValidGeometryOwnerLocked(ac))
225 s.geometryMu.Unlock()
226}
227
228// unregisterAttachmentLocked removes only ac and leaves every other
229// attachment's view intact. Caller holds s.mu.
230func (s *session) unregisterAttachmentLocked(ac *attachedClient) bool {
231 if s == nil {
232 return false
233 }
234 return s.sessionCore.unregisterAttachmentLocked(ac)
235}
236
237func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool {
238 if c == nil || ac == nil || c.attachments == nil {
239 return false
240 }
241 if _, exists := c.attachments[ac]; !exists {
242 return false
243 }
244 delete(c.attachments, ac)
245 delete(c.attachmentOrder, ac)
246 c.refreshGeometryOwnerLocked()
247 return true
248}
249
250func unregisterAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
251 if entry == nil || entry.core() == nil {
252 return false
253 }
254 return entry.core().unregisterAttachmentLocked(ac)
255}
256
257func (s *session) unregisterAttachment(ac *attachedClient) bool {
258 if s == nil || ac == nil {
259 return false
260 }
261 s.dispatchMu.Lock()
262 defer s.dispatchMu.Unlock()
263 s.mu.Lock()
264 defer s.mu.Unlock()
265 return s.unregisterAttachmentLocked(ac)
266}
267
268func (s *session) snapshotAttachmentsLocked() []*attachedClient {
269 if s == nil || len(s.attachments) == 0 {
270 return nil
271 }
272 out := make([]*attachedClient, 0, len(s.attachments))
273 for ac := range s.attachments {
274 out = append(out, ac)
275 }
276 slices.SortStableFunc(out, func(a, b *attachedClient) int {
277 // ClientID is the stable wire identity, so concurrent registration order
278 // cannot affect snapshots or invalidation order.
279 if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 {
280 return id
281 }
282 return cmp.Compare(s.attachmentOrder[a], s.attachmentOrder[b])
283 })
284 return out
285}
286
287func attachmentRegisteredLocked(entry *session, ac *attachedClient) bool {
288 if entry == nil || ac == nil || entry.core() == nil {
289 return false
290 }
291 _, ok := entry.core().attachments[ac]
292 return ok
293}
294
295func attachmentRegistered(entry *session, ac *attachedClient) bool {
296 if entry == nil || ac == nil || entry.core() == nil {
297 return false
298 }
299 core := entry.core()
300 core.mu.Lock()
301 defer core.mu.Unlock()
302 return attachmentRegisteredLocked(entry, ac)
303}
304
305func (s *session) attachmentRegistered(ac *attachedClient) bool {
306 return attachmentRegistered(s, ac)
307}
308
309func (s *session) attachmentRegisteredLocked(ac *attachedClient) bool {
310 return attachmentRegisteredLocked(s, ac)
311}
312
313func (s *session) attachmentViewsTabLocked(tb *tab) bool {
314 if s == nil || tb == nil {
315 return false
316 }
317 for ac := range s.attachments {
318 if ac.viewSnapshot().tabID == domain.TabStableID(tb.stableID) {
319 return true
320 }
321 }
322 return false
323}
324
325func (s *session) snapshotAttachments() []*attachedClient {
326 if s == nil {
327 return nil
328 }
329 s.mu.Lock()
330 defer s.mu.Unlock()
331 return s.snapshotAttachmentsLocked()
332}
333
334// repairAttachmentViewLocked validates stable targets and chooses the nearest
335// surviving target when a tab or pane was removed. Caller holds s.mu.
336func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachmentView) attachmentView {
337 _ = ac
338 if s == nil {
339 return attachmentView{}
340 }
341 var targetTab *tab
342 for _, tb := range s.tabs {
343 if tb != nil && domain.TabStableID(tb.stableID) == view.tabID {
344 targetTab = tb
345 break
346 }
347 }
348 tabRepaired := targetTab == nil
349 if targetTab == nil && len(s.tabs) != 0 {
350 targetTab = s.tabs[0]
351 }
352 if targetTab == nil {
353 view.tabID = ""
354 view.paneID = ""
355 view.windowTop = 0
356 view.windowRows = 0
357 view.windowSet = false
358 view.bookmark = 0
359 view.liveBottom = true
360 return view
361 }
362 view.tabID = domain.TabStableID(targetTab.stableID)
363
364 targetPane := (*pane)(nil)
365 targetTab.mu.Lock()
366 for _, candidate := range targetTab.panes {
367 if candidate != nil && domain.PaneStableID(candidate.stableID) == view.paneID {
368 targetPane = candidate
369 break
370 }
371 }
372 paneRepaired := targetPane == nil
373 if targetPane == nil {
374 targetPane = targetTab.focusedPane()
375 }
376 if targetPane != nil {
377 view.paneID = domain.PaneStableID(targetPane.stableID)
378 } else {
379 view.paneID = ""
380 }
381 targetTab.mu.Unlock()
382 if view.windowRows <= 0 {
383 targetTab.mu.Lock()
384 view.windowRows = targetTab.size.Rows
385 targetTab.mu.Unlock()
386 }
387 if view.windowTop < 0 {
388 view.windowTop = 0
389 }
390 if tabRepaired || paneRepaired || targetPane == nil {
391 view.bookmark = 0
392 view.liveBottom = true
393 }
394 return view
395}
396
397// repairAttachmentView repairs ac and increments its revision only when the
398// stable target changed. It is deliberately separate from registration so
399// layout mutation code can validate every surviving attachment in one ordered
400// pass.
401func (s *session) repairAttachmentView(ac *attachedClient) bool {
402 if s == nil || ac == nil {
403 return false
404 }
405 before := ac.viewSnapshot()
406 s.mu.Lock()
407 if _, ok := s.attachments[ac]; !ok {
408 s.mu.Unlock()
409 return false
410 }
411 after := s.repairAttachmentViewLocked(ac, before)
412 if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.windowSet == after.windowSet && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom {
413 s.mu.Unlock()
414 return false
415 }
416 after.revision++
417 s.mu.Unlock()
418 return ac.publishViewIfCurrent(before, after)
419}
420
421// prepareAttachmentViewsForRemovedTabLocked selects the nearest surviving tab
422// for attachments that were viewing removed. Caller holds s.mu before tabs is
423// mutated; the normal invalidation pass publishes the revised views.
424func (s *session) prepareAttachmentViewsForRemovedTabLocked(removed *tab, index int) {
425 if s == nil || removed == nil || len(s.tabs) <= 1 || index < 0 || index >= len(s.tabs) {
426 return
427 }
428 replacement := index + 1
429 if replacement >= len(s.tabs) {
430 replacement = index - 1
431 }
432 if replacement < 0 || replacement >= len(s.tabs) || s.tabs[replacement] == nil {
433 return
434 }
435 tabID := domain.TabStableID(s.tabs[replacement].stableID)
436 removedID := domain.TabStableID(removed.stableID)
437 for ac := range s.attachments {
438 view := ac.viewSnapshot()
439 if view.tabID == removedID {
440 view.tabID = tabID
441 view.paneID = ""
442 view.bookmark = 0
443 view.liveBottom = true
444 view.revision++
445 ac.publishView(view)
446 }
447 }
448}
449
450// invalidateViewsLocked orders all per-attachment invalidations behind the
451// shared session mutation. Caller holds s.mu and dispatchMu.
452func (s *session) invalidateViewsLocked() {
453 for _, ac := range s.snapshotAttachmentsLocked() {
454 view := ac.viewSnapshot()
455 repaired := s.repairAttachmentViewLocked(ac, view)
456 repaired.revision++
457 ac.publishView(repaired)
458 }
459}
460
461// runMutation is the one admission boundary for shared session mutations.
462// Callers perform their state mutation and view invalidation inside fn; the
463// dispatch lock is acquired before any session, tab, or daemon lock.
464func (s *session) runMutation(fn func() error) error {
465 if s == nil || fn == nil {
466 return nil
467 }
468 s.dispatchMu.Lock()
469 defer s.dispatchMu.Unlock()
470 return fn()
471}
472
473// tabForAttachment resolves the attachment's stable target after validating
474// it against current membership. It never changes another attachment's view.
475func (s *session) tabForAttachment(ac *attachedClient) *tab {
476 if s == nil || ac == nil {
477 return nil
478 }
479 for {
480 view := ac.viewSnapshot()
481 s.mu.Lock()
482 if _, ok := s.attachments[ac]; !ok {
483 s.mu.Unlock()
484 return nil
485 }
486 repaired := s.repairAttachmentViewLocked(ac, view)
487 var target *tab
488 for _, tb := range s.tabs {
489 if tb != nil && domain.TabStableID(tb.stableID) == repaired.tabID {
490 target = tb
491 break
492 }
493 }
494 s.mu.Unlock()
495
496 if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.windowSet != view.windowSet || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom {
497 repaired.revision++
498 }
499 if !ac.publishViewIfCurrent(view, repaired) {
500 continue
501 }
502 return target
503 }
504}
505
506// paneForAttachment resolves both stable IDs and repairs a removed pane before
507// returning it. The tab remains shared; only the attachment's view moves.
508func (s *session) tabIndexForAttachment(ac *attachedClient) (int, int) {
509 if s == nil {
510 return -1, 0
511 }
512 s.mu.Lock()
513 tabs := append([]*tab(nil), s.tabs...)
514 s.mu.Unlock()
515 if len(tabs) == 0 {
516 return -1, 0
517 }
518 if ac == nil {
519 return 0, len(tabs)
520 }
521 view := ac.viewSnapshot()
522 for i, tb := range tabs {
523 if tb != nil && domain.TabStableID(tb.stableID) == view.tabID {
524 return i, len(tabs)
525 }
526 }
527 return 0, len(tabs)
528}
529
530// firstTab returns the first tab in the session's ordered tab collection.
531// It is a deterministic session-level default for headless work, never an
532// interactive attachment selection.
533func (s *session) firstTab() *tab {
534 if s == nil {
535 return nil
536 }
537 s.mu.Lock()
538 defer s.mu.Unlock()
539 if len(s.tabs) == 0 {
540 return nil
541 }
542 return s.tabs[0]
543}
544
545func (s *session) paneForAttachment(ac *attachedClient) (*tab, *pane) {
546 tb := s.tabForAttachment(ac)
547 if tb == nil {
548 return nil, nil
549 }
550 view := ac.viewSnapshot()
551 tb.mu.Lock()
552 defer tb.mu.Unlock()
553 for _, p := range tb.panes {
554 if p != nil && domain.PaneStableID(p.stableID) == view.paneID {
555 return tb, p
556 }
557 }
558 return tb, tb.focusedPane()
559}
560
561// setAttachmentPaneLocked publishes the initiating attachment's pane target
562// after a shared focus mutation. Caller holds s.mu; the structural tree focus
563// is not used to resolve any other attachment's target.
564func (s *session) setAttachmentPaneLocked(ac *attachedClient, tb *tab, p *pane) bool {
565 if s == nil || ac == nil || tb == nil || p == nil || !attachmentRegisteredLocked(s, ac) {
566 return false
567 }
568 view := ac.viewSnapshot()
569 before := view
570 view.tabID = domain.TabStableID(tb.stableID)
571 view.paneID = domain.PaneStableID(p.stableID)
572 view = s.repairAttachmentViewLocked(ac, view)
573 if view.tabID == before.tabID && view.paneID == before.paneID {
574 return false
575 }
576 view.revision++
577 ac.publishView(view)
578 return true
579}
580
581// updateAttachmentView applies one attachment-local view mutation at the
582// shared mutation boundary. The revision increments exactly once and target
583// IDs are repaired before publication.
584func (s *session) updateAttachmentView(ac *attachedClient, update func(*attachmentView)) bool {
585 if s == nil || ac == nil || update == nil {
586 return false
587 }
588 changed := false
589 _ = s.runMutation(func() error {
590 s.mu.Lock()
591 defer s.mu.Unlock()
592 if _, ok := s.attachments[ac]; !ok {
593 return nil
594 }
595 before := ac.viewSnapshot()
596 next := before
597 update(&next)
598 next = s.repairAttachmentViewLocked(ac, next)
599 next.revision++
600 ac.publishView(next)
601 changed = next.tabID != before.tabID || next.paneID != before.paneID || next.windowTop != before.windowTop || next.windowRows != before.windowRows || next.windowSet != before.windowSet || next.bookmark != before.bookmark || next.liveBottom != before.liveBottom
602 return nil
603 })
604 return changed
605}
606
607// selectAttachmentTab changes only one attachment's target. Shared tab order
608// and PTY ownership are untouched.
609func (s *session) activateAttachmentViewLocked(ac *attachedClient, idx int) bool {
610 if s == nil || ac == nil || idx < 0 || idx >= len(s.tabs) || !attachmentRegisteredLocked(s, ac) {
611 return false
612 }
613 view := ac.viewSnapshot()
614 view.tabID = domain.TabStableID(s.tabs[idx].stableID)
615 view.paneID = ""
616 view = s.repairAttachmentViewLocked(ac, view)
617 view.revision++
618 ac.publishView(view)
619 return true
620}
621
622func (s *session) selectAttachmentTabLocked(ac *attachedClient, tabID domain.TabStableID) bool {
623 if s == nil || ac == nil || !attachmentRegisteredLocked(s, ac) {
624 return false
625 }
626 view := ac.viewSnapshot()
627 view.tabID = tabID
628 view.paneID = ""
629 view = s.repairAttachmentViewLocked(ac, view)
630 view.revision++
631 ac.publishView(view)
632 s.invalidateViewsLocked()
633 return view.tabID == tabID
634}
635
636func (s *session) selectAttachmentTab(ac *attachedClient, tabID domain.TabStableID) bool {
637 if s == nil || ac == nil {
638 return false
639 }
640 var changed bool
641 _ = s.runMutation(func() error {
642 s.mu.Lock()
643 defer s.mu.Unlock()
644 changed = s.selectAttachmentTabLocked(ac, tabID)
645 return nil
646 })
647 return changed
648}
649
650func (s *session) switchAttachmentTabForDispatch(ac *attachedClient, idx int) bool {
651 if s == nil || ac == nil {
652 return false
653 }
654 s.mu.Lock()
655 defer s.mu.Unlock()
656 if idx < 0 || idx >= len(s.tabs) {
657 return false
658 }
659 return s.selectAttachmentTabLocked(ac, domain.TabStableID(s.tabs[idx].stableID))
660}
661
662func (s *session) switchAttachmentTab(ac *attachedClient, idx int) bool {
663 if s == nil || ac == nil {
664 return false
665 }
666 s.mu.Lock()
667 registered := attachmentRegisteredLocked(s, ac)
668 if registered && idx >= 0 && idx < len(s.tabs) {
669 tabID := domain.TabStableID(s.tabs[idx].stableID)
670 s.mu.Unlock()
671 return s.selectAttachmentTab(ac, tabID)
672 }
673 s.mu.Unlock()
674 return false
675}
676
677func (s *session) switchAttachmentRelativeForDispatch(ac *attachedClient, delta int) bool {
678 position, count := s.tabIndexForAttachment(ac)
679 if count < 2 {
680 return false
681 }
682 return s.switchAttachmentTabForDispatch(ac, (position+delta+count)%count)
683}
684
685// repairAttachmentViews repairs all live attachment targets after a shared
686// topology mutation.
687func (s *session) repairAttachmentViews() {
688 if s == nil {
689 return
690 }
691 _ = s.runMutation(func() error {
692 s.mu.Lock()
693 s.invalidateViewsLocked()
694 s.mu.Unlock()
695 return nil
696 })
697}
698
699// repairAttachmentViewsLocked repairs all live attachment targets after a tab
700// or pane mutation. It is intended to run at the same mutation linearization
701// point as the shared membership change.
702func (s *session) repairAttachmentViewsLocked() {
703 if s == nil {
704 return
705 }
706 s.invalidateViewsLocked()
707}