v0.3.0 99dc266

transactional_resize.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5
6	"github.com/bnema/vev/internal/domain"
7	"github.com/bnema/vev/internal/ports"
8	"github.com/bnema/vev/internal/usecase/layout"
9)
10
11type resizeOwnerPostEffect uint8
12
13const (
14	resizeOwnerPostSnapshotDirty resizeOwnerPostEffect = iota
15	resizeOwnerPostRetrySchedule
16	resizeOwnerPostRenderInvalidation
17	resizeOwnerPostCommitPublication
18)
19
20// resizeMember is an unpublished layout decision. Prepare only reads guarded
21// state; apply performs the external PTY call; commit is the sole publisher.
22type resizeMember struct {
23	session    *session
24	tab        *tab
25	pane       *pane
26	owner      paneEffectLease
27	rect       domain.Rect
28	floating   floatingGeometry
29	isFloating bool
30	// floatingGeneration identifies the exact popup slot accepted by a failed
31	// resize. Floating panes are intentionally outside tb.panes, so retries
32	// must validate this generation and pointer rather than use tiled lookup.
33	floatingGeneration uint64
34	retry              bool
35	ok                 bool
36	err                error
37	screenResized      bool
38}
39
40type preparedTabLayout struct {
41	tab          *tab
42	generation   uint64
43	size         domain.Size
44	previousSize domain.Size
45	members      []resizeMember
46}
47
48func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout {
49	return prepareTabLayoutForSizeLocked(sess, tb, tb.size)
50}
51
52func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) preparedTabLayout {
53	plan := preparedTabLayout{tab: tb, generation: tb.layoutGeneration, size: size, previousSize: tb.size}
54	if tb.tree == nil || tb.tree.Root == nil || !size.Valid() {
55		return plan
56	}
57	area := domain.Rect{Width: size.Cols, Height: size.Rows}
58	placements, ok := layout.Solve(tb.tree.Root, area)
59	if !ok && tb.tree.Root.Kind == layout.Leaf {
60		placements = []layout.Placement{{ID: tb.tree.Root.Leaf, Content: area}}
61		ok = true
62	}
63	if !ok {
64		return plan
65	}
66	for _, placement := range placements {
67		if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 {
68			continue
69		}
70		if p := tb.panes[placement.ID]; p != nil {
71			p.mu.Lock()
72			owner := p.effectLeaseLocked()
73			p.mu.Unlock()
74			plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, owner: owner, rect: placement.Content})
75		}
76	}
77	return plan
78}
79
80// applyPreparedTabMembers performs only the external PTY phase. A successful
81// resize leaves the parser gate closed until the complete plan is validated.
82func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
83	for i := range plan.members {
84		member := &plan.members[i]
85		p := member.pane
86		p.resizeMu.Lock()
87		p.mu.Lock()
88		if !resizeMemberOwnerCurrent(member) {
89			member.retry = p.owner.Load() != nil
90			p.resizeRetry = member.retry
91			p.mu.Unlock()
92			p.resizeMu.Unlock()
93			continue
94		}
95		old := p.rect
96		pty := p.pty
97		// A rejected earlier attempt deliberately leaves its parser gate open.
98		// Reapply even if the committed rectangle already matches: the PTY may
99		// still be at that rejected attempt's external size. An accepted failed
100		// attempt records resizeRetry for the same reason after its gate closes.
101		needsApply := p.resizeApplying || p.resizeRetry || old.Width != member.rect.Width || old.Height != member.rect.Height
102		if needsApply && pty != nil {
103			p.resizeApplying = true
104		}
105		p.mu.Unlock()
106		if !needsApply || pty == nil {
107			member.ok = true
108		} else if err := pty.Resize(rectSize(member.rect)); err != nil {
109			d.log.Warn("pty resize failed", "err", err)
110			p.mu.Lock()
111			current := resizeMemberOwnerCurrent(member)
112			p.resizeRetry = true
113			p.mu.Unlock()
114			d.replayResizePending(member.session, member.tab, p, false, member.rect)
115			member.retry = true
116			if current {
117				member.err = err
118			}
119		} else {
120			p.mu.Lock()
121			current := resizeMemberOwnerCurrent(member)
122			if !current && p.owner.Load() != nil {
123				p.resizeRetry = true
124				member.retry = true
125			}
126			p.mu.Unlock()
127			member.ok = current
128			member.screenResized = true // records that this member owns an open gate
129		}
130		p.resizeMu.Unlock()
131	}
132}
133
134func resizeMemberOwnerCurrent(member *resizeMember) bool {
135	if member == nil || member.pane == nil {
136		return false
137	}
138	if member.owner.owner == nil {
139		return true
140	}
141	owner := member.pane.owner.Load()
142	return owner == member.owner.owner && owner.session == member.session && owner.tab == member.tab
143}
144
145func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool {
146	if tb.layoutGeneration != plan.generation || tb.size != plan.previousSize {
147		return false
148	}
149	for i := range plan.members {
150		member := &plan.members[i]
151		if tb.panes[member.pane.id] != member.pane || !resizeMemberOwnerCurrent(member) {
152			return false
153		}
154	}
155	return true
156}
157
158func resizeMembersOwnerCurrent(members []resizeMember) bool {
159	for i := range members {
160		if !resizeMemberOwnerCurrent(&members[i]) {
161			return false
162		}
163	}
164	return true
165}
166
167func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool {
168	for i := range plan.members {
169		member := &plan.members[i]
170		member.pane.mu.Lock()
171		current := resizeMemberOwnerCurrent(member)
172		if !current {
173			member.pane.resizeRetry = member.pane.owner.Load() != nil
174			member.retry = member.pane.resizeRetry
175		}
176		member.pane.mu.Unlock()
177		if !current {
178			return false
179		}
180	}
181	for i := range plan.members {
182		member := &plan.members[i]
183		member.pane.mu.Lock()
184		member.pane.rect = member.rect
185		if member.ok {
186			member.pane.resizeRetry = false
187			member.pane.screen.Resize(member.rect.Width, member.rect.Height)
188		}
189		member.pane.mu.Unlock()
190	}
191	return true
192}
193
194// cancelStalePreparedGates releases gates for members which disappeared from
195// the next solved layout (for example a stack member collapsed by a focus
196// change). Members that remain are deliberately left gated for the retry.
197func (d *Daemon) cancelStalePreparedGates(sess *session, tb *tab, plan *preparedTabLayout) {
198	tb.mu.Lock()
199	latest := prepareTabLayoutLocked(sess, tb)
200	tb.mu.Unlock()
201	current := make(map[*pane]struct{}, len(latest.members))
202	for i := range latest.members {
203		current[latest.members[i].pane] = struct{}{}
204	}
205	for i := range plan.members {
206		member := &plan.members[i]
207		if !member.screenResized {
208			continue
209		}
210		if _, ok := current[member.pane]; ok {
211			continue
212		}
213		member.pane.resizeMu.Lock()
214		d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
215		member.pane.resizeMu.Unlock()
216	}
217}
218
219func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool) {
220	if !accepted {
221		// cancelStalePreparedGates owns stale-plan cancellation. Keeping this
222		// path empty ensures a removed member's pending bytes are replayed once,
223		// after the fresh layout determines it cannot survive the retry.
224		return
225	}
226	for i := range plan.members {
227		member := &plan.members[i]
228		if !member.screenResized {
229			continue
230		}
231		member.pane.resizeMu.Lock()
232		d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
233		member.pane.resizeMu.Unlock()
234	}
235}
236
237// applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns
238// one per-tab single-writer loop, but never holds tab or pane state locks while
239// invoking PTY.Resize.
240func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) ([]resizeMember, bool) {
241	return d.applyTabLayoutTransactionWithNotice(sess, tb, true, current...)
242}
243
244func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, reportFailure bool, current ...func() bool) ([]resizeMember, bool) {
245	if tb == nil {
246		return nil, false
247	}
248	tb.layoutApplyMu.Lock()
249	defer tb.layoutApplyMu.Unlock()
250	for {
251		if tb.ctx != nil && tb.ctx.Err() != nil {
252			return nil, false
253		}
254		if len(current) != 0 && !current[0]() {
255			return nil, false
256		}
257		tb.mu.Lock()
258		plan := prepareTabLayoutLocked(sess, tb)
259		tb.mu.Unlock()
260		d.applyPreparedTabMembers(&plan)
261
262		tb.mu.Lock()
263		accepted := validatePreparedTabLayoutLocked(tb, &plan)
264		if len(current) != 0 && !current[0]() {
265			accepted = false
266		}
267		if accepted {
268			accepted = commitPreparedTabLayoutLocked(&plan)
269		}
270		tb.mu.Unlock()
271		d.finishPreparedTabMembers(&plan, accepted)
272		if !accepted {
273			d.cancelStalePreparedGates(sess, tb, &plan)
274			if len(current) != 0 && !current[0]() {
275				for i := range plan.members {
276					member := &plan.members[i]
277					if !member.screenResized {
278						continue
279					}
280					member.pane.resizeMu.Lock()
281					d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
282					member.pane.resizeMu.Unlock()
283				}
284				return nil, false
285			}
286			continue
287		}
288		failed := make([]resizeMember, 0)
289		for _, member := range plan.members {
290			if !member.ok {
291				failed = append(failed, member)
292			}
293		}
294		if reportFailure && len(failed) != 0 && resizeMembersOwnerCurrent(failed) {
295			d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
296				"pane resize failed; retrying in background", failed[len(failed)-1].err)
297		}
298		return failed, true
299	}
300}
301
302func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool {
303	failed, ok := d.applyTabLayoutTransaction(sess, tb)
304	if !ok {
305		return false
306	}
307	markSnapshotDirty(sess)
308	if len(failed) != 0 {
309		d.scheduleAcceptedTabLayoutRetry(sess, tb)
310	}
311	return true
312}
313
314const maxAcceptedTabLayoutRetries = 3
315
316// scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The
317// worker is deduplicated, derives cancellation from the tab lifecycle, and
318// suppresses repeat degradation notices after the accepted initial failure.
319func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) {
320	if d.clock == nil || tb == nil || tb.ctx == nil {
321		return
322	}
323	tb.layoutRetryMu.Lock()
324	if tb.layoutRetryRunning {
325		tb.layoutRetryMu.Unlock()
326		return
327	}
328	ctx, cancel := context.WithCancel(tb.ctx)
329	tb.layoutRetryRunning = true
330	tb.layoutRetryCancel = cancel
331	tb.layoutRetryMu.Unlock()
332
333	go func() {
334		defer func() {
335			cancel()
336			tb.layoutRetryMu.Lock()
337			tb.layoutRetryRunning = false
338			tb.layoutRetryCancel = nil
339			tb.layoutRetryMu.Unlock()
340		}()
341		for range maxAcceptedTabLayoutRetries {
342			timer := d.clock.NewTimer(minOutputRenderDeadline)
343			if timer == nil {
344				return
345			}
346			timerC := timer.C()
347			if timerC == nil {
348				timer.Stop()
349				return
350			}
351			select {
352			case <-ctx.Done():
353				timer.Stop()
354				return
355			case <-timerC:
356			}
357			if ctx.Err() != nil {
358				return
359			}
360			failed, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool { return ctx.Err() == nil })
361			if !ok || len(failed) == 0 {
362				return
363			}
364			markSnapshotDirty(sess)
365			for _, ac := range sess.snapshotAttachments() {
366				d.invalidateRender(sess, ac, true, "transactional_resize.go")
367			}
368		}
369	}()
370}
371
372// replayResizePending completes a pane's apply epoch. It keeps the gate set
373// while each buffered batch goes through the normal VT path, so reads arriving
374// during replay join the same ordered stream. Failure deliberately omits the
375// Screen.Resize, parsing every byte at the old dimensions.
376func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bool, size domain.Rect) {
377	first := true
378	for {
379		p.mu.Lock()
380		if first && resized {
381			p.screen.Resize(size.Width, size.Height)
382		}
383		first = false
384		data := append([]byte(nil), p.resizePending...)
385		p.resizePending = p.resizePending[:0]
386		if len(data) == 0 {
387			p.resizeApplying = false
388			p.mu.Unlock()
389			return
390		}
391		p.mu.Unlock()
392		if sess == nil {
393			p.mu.Lock()
394			p.screen.Write(data)
395			p.refreshTerminalTitleLocked()
396			p.mu.Unlock()
397			continue
398		}
399		d.processPTYData(sess, tb, p, data, false)
400	}
401}
402
403// applyVisibleFloatingLayout retains floating panes' existing independent
404// lifecycle while routing their external resize through the same lock-free gate
405// used for retryable PTY work. Floating state is not part of a tiled layout
406// generation, so its slot generation and exact pane identity validate its
407// publication instead.
408func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) ([]resizeMember, bool) {
409	return d.applyVisibleFloatingLayoutForMember(sess, tb, current, nil)
410}
411
412// applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the
413// exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes,
414// so accepting a replacement here would silently retry unrelated geometry.
415func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, current func() bool, expected *resizeMember) ([]resizeMember, bool) {
416	if tb == nil {
417		return nil, true
418	}
419	tb.mu.Lock()
420	if tb.floating.state != floatingVisible || tb.floating.pane == nil ||
421		(expected != nil && (tb.floating.generation != expected.floatingGeneration || tb.floating.pane != expected.pane)) {
422		tb.mu.Unlock()
423		return nil, true
424	}
425	p := tb.floating.pane
426	generation := tb.floating.generation
427	size := tb.size
428	geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig())
429	tb.mu.Unlock()
430	if !geometry.committable() {
431		return nil, true
432	}
433	if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {
434		// A drawer can validly reserve only its separator row. Publish that
435		// presentation without inventing a physical PTY size; the last usable
436		// rectangle and screen remain committed until a later resize has content.
437		tb.mu.Lock()
438		currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation &&
439			tb.floating.pane == p && tb.size == size
440		if current != nil && !current() {
441			currentSlot = false
442		}
443		if currentSlot {
444			p.mu.Lock()
445			p.popupGeometry = geometry
446			p.mu.Unlock()
447			tb.mu.Unlock()
448			return nil, true
449		}
450		tb.mu.Unlock()
451		if current != nil && !current() {
452			return nil, false
453		}
454		return nil, true
455	}
456
457	// This keeps successful PTY resizes gated until the
458	// floating slot and tab size are revalidated. A newer client resize may
459	// otherwise publish this obsolete popup geometry after its PTY call returns.
460	p.mu.Lock()
461	owner := p.effectLeaseLocked()
462	p.mu.Unlock()
463	plan := preparedTabLayout{members: []resizeMember{{
464		session: sess, tab: tb, pane: p, owner: owner, rect: geometry.ptyRect(), floating: geometry, isFloating: true, floatingGeneration: generation,
465	}}}
466	d.applyPreparedTabMembers(&plan)
467
468	// The PTY may have accepted an intermediate size, but a hidden, replaced,
469	// relaunched, or resized slot must never receive this attempt's geometry.
470	tb.mu.Lock()
471	currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size && resizeMemberOwnerCurrent(&plan.members[0])
472	if current != nil && !current() {
473		currentSlot = false
474	}
475	if currentSlot && plan.members[0].ok {
476		p.mu.Lock()
477		currentSlot = resizeMemberOwnerCurrent(&plan.members[0])
478		if currentSlot {
479			p.rect = plan.members[0].rect
480			p.popupGeometry = geometry
481			p.resizeRetry = false
482			p.screen.Resize(plan.members[0].rect.Width, plan.members[0].rect.Height)
483		}
484		p.mu.Unlock()
485	}
486	tb.mu.Unlock()
487	if !currentSlot {
488		// This attempt has no tiled transaction loop to retain the gate for, so
489		// discard its buffered bytes at the old screen size before returning.
490		for i := range plan.members {
491			member := &plan.members[i]
492			if !member.screenResized {
493				continue
494			}
495			member.pane.resizeMu.Lock()
496			d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
497			member.pane.resizeMu.Unlock()
498		}
499		if current != nil && !current() {
500			return nil, false
501		}
502		return nil, true
503	}
504	if plan.members[0].ok {
505		d.finishPreparedTabMembers(&plan, true)
506		return nil, true
507	}
508	return plan.members, true
509}
510
511// releasePreparedSessionGates abandons a session attempt which cannot be
512// retried here (for example because a newer coordinator epoch won). Unlike a
513// stale per-tab retry, no succeeding attempt is owned by this call, so every
514// successful external resize must reopen its parser gate at the old screen
515// size.
516func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) {
517	for _, plan := range plans {
518		if plan == nil {
519			continue
520		}
521		for i := range plan.members {
522			member := &plan.members[i]
523			if !member.screenResized {
524				continue
525			}
526			member.pane.resizeMu.Lock()
527			d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
528			member.pane.resizeMu.Unlock()
529		}
530	}
531}
532
533// applySessionLayout keeps client resize publication as a two-phase session
534// transaction: all PTYs are applied and plans validated first, then the
535// coordinator admits the epoch before any tab size, rectangle, VT screen,
536// snapshot dirtiness, or resize telemetry becomes visible.
537func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) {
538	if sess == nil {
539		return nil, false
540	}
541	sess.layoutApplyMu.Lock()
542	defer sess.layoutApplyMu.Unlock()
543
544	target := contentSize(size)
545	for {
546		if sess.ctx != nil && sess.ctx.Err() != nil {
547			return nil, false
548		}
549		if current != nil && !current() {
550			return nil, false
551		}
552		sess.mu.Lock()
553		tabs := append([]*tab(nil), sess.tabs...)
554		sess.mu.Unlock()
555		plans := make([]*preparedTabLayout, 0, len(tabs))
556		for _, tb := range tabs {
557			tb.mu.Lock()
558			plan := prepareTabLayoutForSizeLocked(sess, tb, target)
559			tb.mu.Unlock()
560			plans = append(plans, &plan)
561		}
562		for _, plan := range plans {
563			d.applyPreparedTabMembers(plan)
564		}
565
566		// Hold every tab lock across final validation, epoch admission, and all
567		// publication. This makes the session commit indivisible to layout
568		// mutators while still keeping PTY.Resize outside every state lock.
569		for _, tb := range tabs {
570			tb.mu.Lock()
571		}
572		valid := true
573		for _, plan := range plans {
574			if !validatePreparedTabLayoutLocked(plan.tab, plan) {
575				valid = false
576				break
577			}
578		}
579		// This is the final external-apply validation boundary. Tests use the
580		// seam to install a newer epoch here; that epoch must reject admission
581		// before this attempt publishes any session geometry.
582		if valid && d.beforeSessionResizePublication != nil {
583			d.beforeSessionResizePublication()
584		}
585		// Claim publication and shared geometry commit use one fence. The hook
586		// above remains outside the fence so tests and lifecycle callbacks can
587		// publish a newer claim; the final validation then rejects this stale plan.
588		sess.geometryMu.Lock()
589		if valid && sess.ctx != nil && sess.ctx.Err() != nil {
590			valid = false
591		}
592		if valid && current != nil && !current() {
593			valid = false
594		}
595		if valid && admit != nil && !admit() {
596			valid = false
597		}
598		if valid {
599			for _, plan := range plans {
600				if !commitPreparedTabLayoutLocked(plan) {
601					valid = false
602					break
603				}
604				plan.tab.size = plan.size
605			}
606		}
607		sess.geometryMu.Unlock()
608		for i := len(tabs) - 1; i >= 0; i-- {
609			tabs[i].mu.Unlock()
610		}
611		if !valid {
612			d.releasePreparedSessionGates(plans)
613			// Headless requests have no attachment epoch. A live tab mutation
614			// still invalidates their plan, but must trigger a fresh prepare rather
615			// than being mistaken for a canceled request.
616			if current != nil && !current() {
617				return nil, false
618			}
619			// A layout mutation invalidated the plans while this epoch remains
620			// current. Reapply the fresh session geometry before admitting it.
621			continue
622		}
623
624		failed := make([]resizeMember, 0)
625		for _, plan := range plans {
626			d.finishPreparedTabMembers(plan, true)
627			for _, member := range plan.members {
628				if !member.ok {
629					failed = append(failed, member)
630				}
631			}
632		}
633		for _, tb := range tabs {
634			floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tb, current)
635			if !ok {
636				return nil, false
637			}
638			failed = append(failed, floatingFailed...)
639		}
640		failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed)
641		if current != nil && !current() {
642			return nil, false
643		}
644		if len(failed) != 0 && failedCurrent {
645			d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
646				"pane resize failed; retrying in background", failed[len(failed)-1].err)
647		}
648		if len(failed) != 0 && !failedCurrent {
649			return nil, false
650		}
651		if current != nil {
652			d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostSnapshotDirty)
653			if !current() {
654				return nil, false
655			}
656		}
657		markSnapshotDirty(sess)
658		d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
659		return failed, true
660	}
661}
662
663func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {
664	rc := sess.renderCoordinator()
665	if rc == nil {
666		return false
667	}
668	snap := rc.resizeSnapshot()
669	geometryClaim := snap.geometryClaim
670	if geometryClaim == 0 {
671		var claimed bool
672		geometryClaim, claimed = sess.claimGeometryOwnerForSize(ac, snap.size)
673		if !claimed {
674			return false
675		}
676	}
677	connectionGeneration := ac.connectionGeneration.Load()
678	connection := ac.transportSnapshot()
679	current := func() bool {
680		return ac.connectionGeneration.Load() == connectionGeneration &&
681			ac.transportSnapshotCurrent(connection) &&
682			sess.geometryClaimTokenCurrent(ac, geometryClaim) &&
683			rc.resizeCurrentForLease(epoch, ac, lease, false)
684	}
685	if !current() {
686		return false
687	}
688	d.exitCopyMode(ac)
689	failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool {
690		return rc.resizeCurrentForLease(epoch, ac, lease, true)
691	})
692	if !ok {
693		return false
694	}
695	// Revalidate while holding the attachment output boundary. A lease can be
696	// retired after the layout commit but before this attachment-local size is
697	// published; stale generations must not publish any remaining effects.
698	d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
699	ac.sendMu.Lock()
700	if !current() {
701		ac.sendMu.Unlock()
702		return false
703	}
704	ac.setSize(snap.size)
705	ac.sendMu.Unlock()
706	if !current() {
707		return false
708	}
709	if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
710		rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
711	}
712	if !current() {
713		return false
714	}
715	d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
716	// A successful transaction publishes exactly one full S2 frame. The
717	// coordinator is the only emission route and stale epochs never reach it.
718	if !current() || !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
719		return false
720	}
721	// The resize debounce has already elapsed. Consume this sticky reset now;
722	// fire preserves ACK and synchronized-output gates rather than scheduling a
723	// second urgent deadline.
724	if !current() {
725		return false
726	}
727	rc.fireCurrent(false)
728	return true
729}
730
731// retryResizeMembers retries failed committed members through a freshly
732// prepared tab transaction. The captured members identify retry candidates
733// only: their rectangles must never cross this delayed boundary, because any
734// later layout mutation may have changed their geometry or removed them.
735func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) {
736	rc := sess.renderCoordinator()
737	if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {
738		return
739	}
740
741	// Retain tab order for deterministic external PTY ordering while collapsing
742	// several failed members from the same tab into one canonical transaction.
743	tabs := make([]*tab, 0, len(members))
744	seen := make(map[*tab]struct{}, len(members))
745	for _, member := range members {
746		if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrent(&member) {
747			continue
748		}
749		if _, ok := seen[member.tab]; ok {
750			continue
751		}
752		seen[member.tab] = struct{}{}
753		tabs = append(tabs, member.tab)
754	}
755
756	failed := make([]resizeMember, 0, len(members))
757	succeeded := false
758	for _, tb := range tabs {
759		if !rc.retryCurrentForLease(epoch, ac, lease) {
760			return
761		}
762		// A later accepted layout transaction clears resizeRetry. In that case
763		// this delayed callback has no remaining work and, importantly, must not
764		// replay the obsolete rectangle it captured before the mutation.
765		retryPending := false
766		tb.mu.Lock()
767		for _, member := range members {
768			if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
769				continue
770			}
771			member.pane.mu.Lock()
772			retryPending = retryPending || member.pane.resizeRetry
773			member.pane.mu.Unlock()
774		}
775		tb.mu.Unlock()
776		if !retryPending {
777			continue
778		}
779
780		// applyTabLayoutTransaction captures the current generation, pane
781		// pointers, and solved rectangles, and validates them again before any
782		// VT/rectangle publication. It also preserves the resize gate and
783		// degradation notice behavior for another failed external attempt.
784		freshFailed, ok := d.applyTabLayoutTransaction(sess, tb, func() bool {
785			return rc.retryCurrentForLease(epoch, ac, lease)
786		})
787		if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
788			return
789		}
790		failed = append(failed, freshFailed...)
791		// A retry success is a formerly failed, still-current target whose
792		// canonical apply cleared its retry bit. A collapsed or removed target
793		// is neither a retry completion nor a reason to publish a reset.
794		tb.mu.Lock()
795		for _, member := range members {
796			if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
797				continue
798			}
799			member.pane.mu.Lock()
800			succeeded = succeeded || !member.pane.resizeRetry
801			member.pane.mu.Unlock()
802		}
803		tb.mu.Unlock()
804	}
805	for _, member := range members {
806		if !member.isFloating || member.tab == nil || member.pane == nil {
807			continue
808		}
809		if !rc.retryCurrentForLease(epoch, ac, lease) {
810			return
811		}
812		// Floating panes are outside tb.panes. Validate the exact accepted slot
813		// before preparing a fresh geometry; applyVisibleFloatingLayout repeats
814		// the same pointer/generation check around external PTY.Resize.
815		member.tab.mu.Lock()
816		currentSlot := member.tab.floating.state == floatingVisible &&
817			member.tab.floating.generation == member.floatingGeneration &&
818			member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
819		retryPending := false
820		if currentSlot {
821			member.pane.mu.Lock()
822			retryPending = member.pane.resizeRetry
823			member.pane.mu.Unlock()
824		}
825		member.tab.mu.Unlock()
826		if !currentSlot || !retryPending {
827			continue
828		}
829		freshFailed, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool {
830			return rc.retryCurrentForLease(epoch, ac, lease)
831		}, &member)
832		if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
833			return
834		}
835		failed = append(failed, freshFailed...)
836		member.tab.mu.Lock()
837		stillCurrent := member.tab.floating.state == floatingVisible &&
838			member.tab.floating.generation == member.floatingGeneration &&
839			member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
840		member.tab.mu.Unlock()
841		if stillCurrent && len(freshFailed) == 0 {
842			member.pane.mu.Lock()
843			succeeded = succeeded || !member.pane.resizeRetry
844			member.pane.mu.Unlock()
845		}
846	}
847	if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
848		rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
849	}
850	if succeeded {
851		// Retry completion changes VT state after the original layout commit.
852		// Keep a named session's eventual snapshot generation aligned with it.
853		markSnapshotDirty(sess)
854		rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
855	}
856}
857
858// requestTransactionalResize reports reset invalidation completion for immediate
859// attached requests, and coordinator schedule acceptance for async requests.
860// Headless requests have no reset invalidation and report geometry completion.
861func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) bool {
862	if ac == nil {
863		return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate)
864	}
865	rc := d.attachCoordinator(sess, nil, ac, true)
866	return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate)
867}
868
869// requestTransactionalResizeForLease carries the connection's captured lease
870// through the full resize transaction and every delayed retry callback.
871func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedClient, lease *attachmentLease, size domain.Size, immediate bool) bool {
872	valid := sess != nil && size.Valid()
873	d.observeRuntime(ports.RuntimeResizeRequested, 0, valid)
874	if !valid {
875		return false
876	}
877	if ac == nil {
878		// Headless geometry has no coordinator/transport to coalesce, but keeps
879		// the same prepare/apply/commit ordering.
880		_, ok := d.applySessionLayout(sess, size, nil, nil)
881		return ok
882	}
883	rc := sess.renderCoordinator()
884	if rc == nil || !rc.leaseCurrent(lease, true) {
885		return false
886	}
887	geometryClaim, claimed := sess.claimGeometryOwnerForSize(ac, size)
888	if !claimed {
889		return false
890	}
891	if immediate {
892		epoch := rc.recordResizeRequestForLeaseWithClaim(size, ac, lease, geometryClaim)
893		if epoch == 0 {
894			sess.releaseGeometryClaim(ac, geometryClaim)
895			return false
896		}
897		return d.runResizeTransaction(sess, ac, lease, epoch)
898	}
899	epoch := rc.scheduleResizeForLeaseWithClaim(size, ac, lease, geometryClaim, func(epoch uint64) { d.runResizeTransaction(sess, ac, lease, epoch) })
900	if epoch == 0 {
901		sess.releaseGeometryClaim(ac, geometryClaim)
902	}
903	return epoch != 0
904}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close