1package daemon
2
3import (
4 "sync"
5 "sync/atomic"
6
7 "github.com/bnema/vev/internal/domain"
8)
9
10// sharedPTYClaim is one immutable attach, resume, or resize claim for shared
11// Session PTY geometry. A Session retains only the latest claim per registered
12// Attachment; historical claims from the same Attachment are never fallback
13// candidates.
14type sharedPTYClaim struct {
15 attachment *attachedClient
16 geometry domain.Geometry
17 sequence uint64
18}
19
20// sharedPTYGeometry is the concrete Session Module for shared PTY geometry.
21// It owns claim publication, latest-valid selection, and accepted geometry.
22// Attachment, render-coordinator, Tab, Pane, floating, and Move state retain
23// their independent owners and are consumed through their existing fences.
24type sharedPTYGeometry struct {
25 mu sync.Mutex
26 latest atomic.Pointer[sharedPTYClaim]
27 claims map[*attachedClient]*sharedPTYClaim // session.mu -> mu
28 sequence uint64 // session.mu -> mu
29 applied domain.Geometry // mu
30}
31
32// claimOutranks reports whether claim a wins over b. A claim from an
33// attachment whose terminal reported losing focus never wins over one that may
34// be seen, so a background window cannot take geometry from the focused one.
35// Otherwise the newest claim wins, as when no terminal reports focus.
36func claimOutranks(a *sharedPTYClaim, aOrder uint64, b *sharedPTYClaim, bOrder uint64) bool {
37 if b == nil {
38 return true
39 }
40 aSeen, bSeen := a.attachment.terminalFocus().MaySee(), b.attachment.terminalFocus().MaySee()
41 if aSeen != bSeen {
42 return aSeen
43 }
44 return a.sequence > b.sequence || (a.sequence == b.sequence && aOrder > bOrder)
45}
46
47// latestValidClaimLocked selects the winning valid remaining Attachment claim
48// (see claimOutranks). The caller holds both session.mu and g.mu.
49func (g *sharedPTYGeometry) latestValidClaimLocked(core *sessionCore, exclude *attachedClient) *sharedPTYClaim {
50 if g == nil || core == nil {
51 return nil
52 }
53 var latest *sharedPTYClaim
54 var latestOrder uint64
55 for attachment := range core.attachments {
56 if attachment == nil || attachment == exclude || !attachment.geometrySnapshot().Valid() {
57 continue
58 }
59 claim := g.claims[attachment]
60 if claim == nil || !claim.geometry.Valid() {
61 continue
62 }
63 order := core.attachmentOrder[attachment]
64 if claimOutranks(claim, order, latest, latestOrder) {
65 latest = claim
66 latestOrder = order
67 }
68 }
69 return latest
70}
71
72// refreshLocked repairs an invalid publication without creating a newer
73// claim. The caller holds session.mu.
74func (g *sharedPTYGeometry) refreshLocked(core *sessionCore) {
75 if g == nil || core == nil {
76 return
77 }
78 g.mu.Lock()
79 defer g.mu.Unlock()
80 latest := g.latest.Load()
81 if latest != nil && latest.attachment != nil && g.claims[latest.attachment] == latest &&
82 latest.geometry.Valid() && latest.attachment.geometrySnapshot().Valid() {
83 if _, registered := core.attachments[latest.attachment]; registered {
84 return
85 }
86 }
87 g.latest.Store(g.latestValidClaimLocked(core, nil))
88}
89
90// claimLocked records a new immutable claim and publishes the winning claim,
91// which is the new one unless an unfocused attachment made it while another
92// may be seen. The caller holds session.mu; claim publication then follows the
93// session.mu -> geometry.mu order.
94func (g *sharedPTYGeometry) claimLocked(core *sessionCore, attachment *attachedClient, geometry domain.Geometry) *sharedPTYClaim {
95 if g == nil || core == nil || attachment == nil || !geometry.Valid() {
96 return nil
97 }
98 g.mu.Lock()
99 defer g.mu.Unlock()
100 if g.claims == nil {
101 g.claims = make(map[*attachedClient]*sharedPTYClaim)
102 }
103 g.sequence++
104 claim := &sharedPTYClaim{
105 attachment: attachment,
106 geometry: geometry.NormalizePixels(),
107 sequence: g.sequence,
108 }
109 g.claims[attachment] = claim
110 if !g.currentHidesLocked(core, claim) {
111 g.latest.Store(claim)
112 }
113 return claim
114}
115
116// currentHidesLocked reports whether the published claim must stay ahead of a
117// newer claim: the newer one comes from an unfocused window while the current
118// one is live and may be seen. The caller holds session.mu and g.mu.
119func (g *sharedPTYGeometry) currentHidesLocked(core *sessionCore, claim *sharedPTYClaim) bool {
120 current := g.latest.Load()
121 if current == nil || current.attachment == claim.attachment {
122 return false
123 }
124 if g.claims[current.attachment] != current {
125 return false // the current claim was superseded or released
126 }
127 if _, registered := core.attachments[current.attachment]; !registered {
128 return false
129 }
130 return !claim.attachment.terminalFocus().MaySee() && current.attachment.terminalFocus().MaySee()
131}
132
133// removeAttachmentLocked removes the exact Attachment claim and republishes
134// the most recent valid remaining claimant. The caller holds session.mu.
135func (g *sharedPTYGeometry) removeAttachmentLocked(core *sessionCore, attachment *attachedClient) {
136 if g == nil || core == nil || attachment == nil {
137 return
138 }
139 g.mu.Lock()
140 defer g.mu.Unlock()
141 delete(g.claims, attachment)
142 latest := g.latest.Load()
143 if latest == nil || latest.attachment == attachment {
144 g.latest.Store(g.latestValidClaimLocked(core, attachment))
145 }
146}
147
148func (g *sharedPTYGeometry) claimAttachment(sess *session, attachment *attachedClient) (*sharedPTYClaim, bool) {
149 if attachment == nil {
150 return nil, false
151 }
152 return g.claimGeometry(sess, attachment, attachment.geometrySnapshot())
153}
154
155func (g *sharedPTYGeometry) claimSize(sess *session, attachment *attachedClient, size domain.Size) (*sharedPTYClaim, bool) {
156 if attachment == nil {
157 return nil, false
158 }
159 // Layout and floating requests carry only shared cells. Preserve the
160 // claimant's complete terminal pixel pair for pane mapping and fallback.
161 geometry := attachment.geometrySnapshot()
162 geometry.Size = size
163 return g.claimGeometry(sess, attachment, geometry)
164}
165
166func (g *sharedPTYGeometry) claimGeometry(sess *session, attachment *attachedClient, geometry domain.Geometry) (*sharedPTYClaim, bool) {
167 if g == nil || sess == nil || attachment == nil || !geometry.Valid() {
168 return nil, false
169 }
170 sess.mu.Lock()
171 defer sess.mu.Unlock()
172 if _, registered := sess.attachments[attachment]; !registered {
173 return nil, false
174 }
175 claim := g.claimLocked(sess.core(), attachment, geometry)
176 return claim, claim != nil
177}
178
179// release removes a claim that did not reach coordinator admission. It removes
180// only the exact current claim and never restores an older claim from the same
181// Attachment.
182func (g *sharedPTYGeometry) release(sess *session, claim *sharedPTYClaim) {
183 if g == nil || sess == nil || claim == nil || claim.attachment == nil {
184 return
185 }
186 sess.mu.Lock()
187 defer sess.mu.Unlock()
188 g.mu.Lock()
189 defer g.mu.Unlock()
190 if g.latest.Load() != claim || g.claims[claim.attachment] != claim {
191 return
192 }
193 delete(g.claims, claim.attachment)
194 g.latest.Store(g.latestValidClaimLocked(sess.core(), claim.attachment))
195}
196
197// sourceSnapshot returns the latest live immutable claim. Refresh takes
198// session.mu only before Tab locks are involved; final transaction validation
199// uses current and remains lock-free.
200func (g *sharedPTYGeometry) sourceSnapshot(sess *session) (*sharedPTYClaim, bool) {
201 if g == nil || sess == nil {
202 return nil, false
203 }
204 sess.mu.Lock()
205 g.refreshLocked(sess.core())
206 claim := g.latest.Load()
207 sess.mu.Unlock()
208 if claim == nil || claim.attachment == nil || !claim.geometry.Valid() {
209 return nil, false
210 }
211 return claim, true
212}
213
214// current is deliberately one lock-free immutable-token comparison. Session
215// layout publication calls it while Tab locks and geometry.mu are held.
216func (g *sharedPTYGeometry) current(claim *sharedPTYClaim) bool {
217 return g != nil && claim != nil && g.latest.Load() == claim
218}
219
220func (g *sharedPTYGeometry) appliedSnapshot() domain.Geometry {
221 if g == nil {
222 return domain.Geometry{}
223 }
224 g.mu.Lock()
225 defer g.mu.Unlock()
226 return g.applied
227}
228
229func (g *sharedPTYGeometry) publishAppliedIfCurrent(claim *sharedPTYClaim) {
230 if g == nil || claim == nil {
231 return
232 }
233 g.mu.Lock()
234 if g.latest.Load() == claim {
235 g.applied = claim.geometry
236 }
237 g.mu.Unlock()
238}
239
240// scaleSharedPTYGeometry is the sole cell/pixel mapping policy. Integer
241// division deliberately truncates because kernel winsize and CSI reports
242// cannot represent fractional pixels. Session construction uses it before a
243// claim can be published; live paths call paneGeometry below.
244func scaleSharedPTYGeometry(full domain.Geometry, size domain.Size) domain.Geometry {
245 geometry := domain.Geometry{Size: size}
246 if !full.Valid() || !size.Valid() || !full.PixelsKnown() {
247 return geometry
248 }
249 geometry.PixelWidth = int(int64(full.PixelWidth) * int64(size.Cols) / int64(full.Cols))
250 geometry.PixelHeight = int(int64(full.PixelHeight) * int64(size.Rows) / int64(full.Rows))
251 return geometry.NormalizePixels()
252}
253
254func (g *sharedPTYGeometry) paneGeometry(size domain.Size) domain.Geometry {
255 if g == nil {
256 return domain.Geometry{Size: size}
257 }
258 claim := g.latest.Load()
259 if claim == nil {
260 return domain.Geometry{Size: size}
261 }
262 return scaleSharedPTYGeometry(claim.geometry, size)
263}
264
265// viewport returns false when no valid Attachment claim exists.
266func (g *sharedPTYGeometry) viewport(sess *session) (domain.Size, *sharedPTYClaim, bool) {
267 claim, ok := g.sourceSnapshot(sess)
268 if !ok {
269 return domain.Size{}, nil, false
270 }
271 content := contentSize(claim.geometry.Size)
272 return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, claim, true
273}
274
275// reconcile applies the latest live Attachment claim to shared Session
276// PTY/layout geometry. An explicit source records a new claim first.
277func (g *sharedPTYGeometry) reconcile(d *Daemon, sess *session, source *attachedClient) bool {
278 if sess == nil {
279 return false
280 }
281 if source != nil {
282 if _, ok := g.claimAttachment(sess, source); !ok {
283 return false
284 }
285 }
286 want, claim, ok := g.viewport(sess)
287 if !ok {
288 return false
289 }
290 current := func() bool { return g.current(claim) }
291 applied := g.appliedSnapshot()
292 pixelGeometryChanged := (applied.PixelsKnown() || claim.geometry.PixelsKnown()) && applied != claim.geometry
293 if !pixelGeometryChanged && contentSize(sess.fullViewportSize()) == contentSize(want) {
294 return false
295 }
296 failed, ok := g.applySessionLayout(d, sess, want, current, nil)
297 if ok {
298 g.publishAppliedIfCurrent(claim)
299 }
300 if ok && len(failed) != 0 {
301 seen := make(map[*tab]struct{}, len(failed))
302 for _, member := range failed {
303 if member.tab == nil {
304 continue
305 }
306 if _, exists := seen[member.tab]; exists {
307 continue
308 }
309 seen[member.tab] = struct{}{}
310 g.scheduleAcceptedTabLayoutRetry(d, sess, member.tab)
311 }
312 }
313 return ok
314}
315
316func (g *sharedPTYGeometry) reconcileAndInvalidate(d *Daemon, sess *session, source *attachedClient, producer string) bool {
317 if !g.reconcile(d, sess, source) {
318 return false
319 }
320 d.invalidateRender(sess, nil, true, producer)
321 return true
322}
323
324func (g *sharedPTYGeometry) reconcileAndInvalidateAsync(d *Daemon, sess *session, producer string) {
325 if g == nil || d == nil {
326 return
327 }
328 d.sessWg.Go(func() {
329 g.reconcileAndInvalidate(d, sess, nil, producer)
330 })
331}