refactor(daemon): route resize through coordinator transactions
- commit
- fd824762939a5400d96250b406e5c4585e98d1a4
- author
- brice · · 2026-07-12 08:02 UTC
- parent
- d74f5ba
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +261 -55
M internal/usecase/daemon/client.go +4 -48
| @@ -621,57 +621,13 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) | |||
| 621 | 621 | } | |
| 622 | 622 | ||
| 623 | 623 | func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |
| 624 | - | d.resizeWithPaint(sess, ac, sz, true) | |
| 624 | + | d.requestTransactionalResize(sess, ac, sz, false) | |
| 625 | 625 | } | |
| 626 | 626 | ||
| 627 | - | // resizeForFirstPaint applies attach geometry without installing an idle timer: | |
| 628 | - | // firstPaint immediately emits the required full frame itself. | |
| 627 | + | // resizeForFirstPaint retains attach's synchronous geometry guarantee. The | |
| 628 | + | // firstPaint caller emits its full frame immediately after this commit. | |
| 629 | 629 | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) { | |
| 630 | - | d.resizeWithPaint(sess, ac, sz, false) | |
| 631 | - | } | |
| 632 | - | ||
| 633 | - | func (d *Daemon) resizeWithPaint(sess *session, ac *attachedClient, sz domain.Size, schedulePaint bool) { | |
| 634 | - | if !sz.Valid() { | |
| 635 | - | return | |
| 636 | - | } | |
| 637 | - | d.exitCopyMode(ac) | |
| 638 | - | if ac != nil { | |
| 639 | - | // Suspend emission until every pane has adopted this geometry and the | |
| 640 | - | // bounded resize paint is armed. PTY readers remain independent. | |
| 641 | - | ac.sendMu.Lock() | |
| 642 | - | defer ac.sendMu.Unlock() | |
| 643 | - | if ac.currentSession() != sess { | |
| 644 | - | return | |
| 645 | - | } | |
| 646 | - | ac.size = sz | |
| 647 | - | if rc := sess.renderCoordinator(); rc != nil { | |
| 648 | - | rc.attach(ac) | |
| 649 | - | rc.recordResizeRequest(sz, ac) | |
| 650 | - | } | |
| 651 | - | } | |
| 652 | - | tbSize := tabSize(sz) | |
| 653 | - | ||
| 654 | - | sess.mu.Lock() | |
| 655 | - | tabs := append([]*tab(nil), sess.tabs...) | |
| 656 | - | sess.mu.Unlock() | |
| 657 | - | if len(tabs) == 0 { | |
| 658 | - | return | |
| 659 | - | } | |
| 660 | - | ||
| 661 | - | for _, tb := range tabs { | |
| 662 | - | tb.mu.Lock() | |
| 663 | - | tb.size = tbSize | |
| 664 | - | d.applyLayoutLocked(tb) | |
| 665 | - | tb.mu.Unlock() | |
| 666 | - | } | |
| 667 | - | // Only the shown tab's floating terminal tracks the client size. This is | |
| 668 | - | // outside tab.mu so its PTY resize cannot block tab state. | |
| 669 | - | d.resizeActiveFloating(sess.activeTab()) | |
| 670 | - | markSnapshotDirty(sess) | |
| 671 | - | if ac != nil && schedulePaint { | |
| 672 | - | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 673 | - | d.scheduleResizePaintLocked(sess, ac) | |
| 674 | - | } | |
| 630 | + | d.requestTransactionalResize(sess, ac, sz, true) | |
| 675 | 631 | } | |
| 676 | 632 | ||
| 677 | 633 | // detachOnSendError drops a client whose transport failed, leaving the session | |
| @@ -621,57 +621,13 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) | |||||
| 621 | } | 621 | } | ||
| 622 | 622 | ||||
| 623 | func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | 623 | func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | ||
| 624 | - | d.resizeWithPaint(sess, ac, sz, true) | 624 | + | d.requestTransactionalResize(sess, ac, sz, false) |
| 625 | } | 625 | } | ||
| 626 | 626 | ||||
| 627 | - | // resizeForFirstPaint applies attach geometry without installing an idle timer: | 627 | + | // resizeForFirstPaint retains attach's synchronous geometry guarantee. The |
| 628 | - | // firstPaint immediately emits the required full frame itself. | 628 | + | // firstPaint caller emits its full frame immediately after this commit. |
| 629 | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) { | 629 | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) { | ||
| 630 | - | d.resizeWithPaint(sess, ac, sz, false) | 630 | + | d.requestTransactionalResize(sess, ac, sz, true) |
| 631 | - | } | |||
| 632 | - | ||||
| 633 | - | func (d *Daemon) resizeWithPaint(sess *session, ac *attachedClient, sz domain.Size, schedulePaint bool) { | |||
| 634 | - | if !sz.Valid() { | |||
| 635 | - | return | |||
| 636 | - | } | |||
| 637 | - | d.exitCopyMode(ac) | |||
| 638 | - | if ac != nil { | |||
| 639 | - | // Suspend emission until every pane has adopted this geometry and the | |||
| 640 | - | // bounded resize paint is armed. PTY readers remain independent. | |||
| 641 | - | ac.sendMu.Lock() | |||
| 642 | - | defer ac.sendMu.Unlock() | |||
| 643 | - | if ac.currentSession() != sess { | |||
| 644 | - | return | |||
| 645 | - | } | |||
| 646 | - | ac.size = sz | |||
| 647 | - | if rc := sess.renderCoordinator(); rc != nil { | |||
| 648 | - | rc.attach(ac) | |||
| 649 | - | rc.recordResizeRequest(sz, ac) | |||
| 650 | - | } | |||
| 651 | - | } | |||
| 652 | - | tbSize := tabSize(sz) | |||
| 653 | - | ||||
| 654 | - | sess.mu.Lock() | |||
| 655 | - | tabs := append([]*tab(nil), sess.tabs...) | |||
| 656 | - | sess.mu.Unlock() | |||
| 657 | - | if len(tabs) == 0 { | |||
| 658 | - | return | |||
| 659 | - | } | |||
| 660 | - | ||||
| 661 | - | for _, tb := range tabs { | |||
| 662 | - | tb.mu.Lock() | |||
| 663 | - | tb.size = tbSize | |||
| 664 | - | d.applyLayoutLocked(tb) | |||
| 665 | - | tb.mu.Unlock() | |||
| 666 | - | } | |||
| 667 | - | // Only the shown tab's floating terminal tracks the client size. This is | |||
| 668 | - | // outside tab.mu so its PTY resize cannot block tab state. | |||
| 669 | - | d.resizeActiveFloating(sess.activeTab()) | |||
| 670 | - | markSnapshotDirty(sess) | |||
| 671 | - | if ac != nil && schedulePaint { | |||
| 672 | - | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |||
| 673 | - | d.scheduleResizePaintLocked(sess, ac) | |||
| 674 | - | } | |||
| 675 | } | 631 | } | ||
| 676 | 632 | ||||
| 677 | // detachOnSendError drops a client whose transport failed, leaving the session | 633 | // detachOnSendError drops a client whose transport failed, leaving the session | ||
M internal/usecase/daemon/render_coordinator.go +104 -7
| @@ -70,9 +70,10 @@ type renderWake struct { | |||
| 70 | 70 | // metadata ownership only — S1/S2 dispatch stays with the retained PR #71 | |
| 71 | 71 | // attachment timer path. | |
| 72 | 72 | type resizeRequestMetadata struct { | |
| 73 | - | size domain.Size | |
| 74 | - | source *attachedClient | |
| 75 | - | epoch uint64 | |
| 73 | + | size domain.Size | |
| 74 | + | source *attachedClient | |
| 75 | + | epoch uint64 // latest requested epoch | |
| 76 | + | committed uint64 // latest published epoch | |
| 76 | 77 | } | |
| 77 | 78 | ||
| 78 | 79 | // renderCoordinatorOptions wires one coordinator instance. | |
| @@ -158,8 +159,13 @@ type renderCoordinator struct { | |||
| 158 | 159 | detached bool | |
| 159 | 160 | torndown bool | |
| 160 | 161 | ||
| 161 | - | resize resizeRequestMetadata | |
| 162 | - | metrics renderCoordinatorBurstMetrics | |
| 162 | + | resize resizeRequestMetadata | |
| 163 | + | // resizeTimer is independent from render invalidation deadlines. It owns | |
| 164 | + | // resize burst coalescing and is cancelled by the same lifecycle changes. | |
| 165 | + | resizeTimer ports.Timer | |
| 166 | + | resizeCancel chan struct{} | |
| 167 | + | resizeGen uint64 | |
| 168 | + | metrics renderCoordinatorBurstMetrics | |
| 163 | 169 | // generation invalidates callbacks from superseded deadline/watchdog timers. | |
| 164 | 170 | // Each worker also owns an explicit cancellation channel: fake timers may | |
| 165 | 171 | // expose a nil or never-firing C channel, so Stop alone cannot release a | |
| @@ -609,7 +615,7 @@ func (c *renderCoordinator) wakeCurrent(w renderWake) bool { | |||
| 609 | 615 | // attachment. | |
| 610 | 616 | func (c *renderCoordinator) noteDetach(ac *attachedClient) { | |
| 611 | 617 | c.mu.Lock() | |
| 612 | - | var timer ports.Timer | |
| 618 | + | var timer, resizeTimer ports.Timer | |
| 613 | 619 | if c.attachment == ac { | |
| 614 | 620 | c.advanceAttachmentEpochLocked(ac) | |
| 615 | 621 | c.attachment = nil | |
| @@ -624,9 +630,15 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) { | |||
| 624 | 630 | c.generation++ | |
| 625 | 631 | c.armed = false | |
| 626 | 632 | timer = c.detachNormalTimerLocked() | |
| 633 | + | if c.resizeCancel != nil { | |
| 634 | + | close(c.resizeCancel) | |
| 635 | + | c.resizeCancel = nil | |
| 636 | + | } | |
| 637 | + | resizeTimer, c.resizeTimer = c.resizeTimer, nil | |
| 627 | 638 | } | |
| 628 | 639 | c.mu.Unlock() | |
| 629 | 640 | stopTimer(timer) | |
| 641 | + | stopTimer(resizeTimer) | |
| 630 | 642 | } | |
| 631 | 643 | ||
| 632 | 644 | // noteReplace hands the coordinator from old to replacement; callbacks | |
| @@ -641,7 +653,7 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin | |||
| 641 | 653 | // which predated coordinator ownership. In that case nil has no pending | |
| 642 | 654 | // identity to invalidate, and the replacement becomes the first bound | |
| 643 | 655 | // attachment atomically with this lifecycle transition. | |
| 644 | - | var timer ports.Timer | |
| 656 | + | var timer, resizeTimer ports.Timer | |
| 645 | 657 | if c.attachment == old || c.attachment == nil { | |
| 646 | 658 | c.advanceAttachmentEpochLocked(old, replacement) | |
| 647 | 659 | c.attachment = replacement | |
| @@ -659,9 +671,15 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin | |||
| 659 | 671 | c.generation++ | |
| 660 | 672 | c.armed = false | |
| 661 | 673 | timer = c.detachNormalTimerLocked() | |
| 674 | + | if c.resizeCancel != nil { | |
| 675 | + | close(c.resizeCancel) | |
| 676 | + | c.resizeCancel = nil | |
| 677 | + | } | |
| 678 | + | resizeTimer, c.resizeTimer = c.resizeTimer, nil | |
| 662 | 679 | } | |
| 663 | 680 | c.mu.Unlock() | |
| 664 | 681 | stopTimer(timer) | |
| 682 | + | stopTimer(resizeTimer) | |
| 665 | 683 | } | |
| 666 | 684 | ||
| 667 | 685 | // notePark invalidates pending wakes when the attachment parks for resume. | |
| @@ -677,6 +695,12 @@ func (c *renderCoordinator) noteSessionTeardown() { | |||
| 677 | 695 | c.previewWake = nil | |
| 678 | 696 | c.previewWakes = nil | |
| 679 | 697 | c.pending = false | |
| 698 | + | if c.resizeCancel != nil { | |
| 699 | + | close(c.resizeCancel) | |
| 700 | + | c.resizeCancel = nil | |
| 701 | + | } | |
| 702 | + | resizeTimer := c.resizeTimer | |
| 703 | + | c.resizeTimer = nil | |
| 680 | 704 | c.ackDeferred = false | |
| 681 | 705 | c.pendingPreview = false | |
| 682 | 706 | c.generation++ | |
| @@ -685,6 +709,7 @@ func (c *renderCoordinator) noteSessionTeardown() { | |||
| 685 | 709 | timers := c.detachSyncTimersLocked() | |
| 686 | 710 | c.mu.Unlock() | |
| 687 | 711 | stopTimer(timer) | |
| 712 | + | stopTimer(resizeTimer) | |
| 688 | 713 | stopTimers(timers) | |
| 689 | 714 | } | |
| 690 | 715 | ||
| @@ -703,6 +728,78 @@ func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attach | |||
| 703 | 728 | return c.resize.epoch | |
| 704 | 729 | } | |
| 705 | 730 | ||
| 731 | + | // scheduleResize records a latest-wins request and runs apply after the bounded | |
| 732 | + | // bulk window. The callback never runs with c.mu held. | |
| 733 | + | func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedClient, run func(uint64)) uint64 { | |
| 734 | + | epoch := c.recordResizeRequest(size, source) | |
| 735 | + | if epoch == 0 { | |
| 736 | + | return 0 | |
| 737 | + | } | |
| 738 | + | c.mu.Lock() | |
| 739 | + | old := c.resizeTimer | |
| 740 | + | if c.resizeCancel != nil { | |
| 741 | + | close(c.resizeCancel) | |
| 742 | + | } | |
| 743 | + | c.resizeGen++ | |
| 744 | + | gen := c.resizeGen | |
| 745 | + | cancel := make(chan struct{}) | |
| 746 | + | c.resizeCancel = cancel | |
| 747 | + | clock := c.opts.clock | |
| 748 | + | c.resizeTimer = nil | |
| 749 | + | c.mu.Unlock() | |
| 750 | + | stopTimer(old) | |
| 751 | + | if clock == nil { | |
| 752 | + | run(epoch) | |
| 753 | + | return epoch | |
| 754 | + | } | |
| 755 | + | timer := clock.NewTimer(minOutputRenderDeadline) | |
| 756 | + | if timer.C() == nil { | |
| 757 | + | stopTimer(timer) | |
| 758 | + | run(epoch) | |
| 759 | + | return epoch | |
| 760 | + | } | |
| 761 | + | c.mu.Lock() | |
| 762 | + | if c.resizeGen != gen || c.torndown { | |
| 763 | + | c.mu.Unlock() | |
| 764 | + | stopTimer(timer) | |
| 765 | + | return epoch | |
| 766 | + | } | |
| 767 | + | c.resizeTimer = timer | |
| 768 | + | c.mu.Unlock() | |
| 769 | + | go func() { | |
| 770 | + | select { | |
| 771 | + | case <-timer.C(): | |
| 772 | + | case <-cancel: | |
| 773 | + | return | |
| 774 | + | } | |
| 775 | + | c.mu.Lock() | |
| 776 | + | valid := !c.torndown && c.resizeGen == gen && c.resize.epoch == epoch && c.resize.source == source | |
| 777 | + | c.resizeTimer = nil | |
| 778 | + | if c.resizeGen == gen { | |
| 779 | + | c.resizeCancel = nil | |
| 780 | + | } | |
| 781 | + | c.mu.Unlock() | |
| 782 | + | if valid { | |
| 783 | + | run(epoch) | |
| 784 | + | } | |
| 785 | + | }() | |
| 786 | + | return epoch | |
| 787 | + | } | |
| 788 | + | ||
| 789 | + | // resizeCurrent verifies that an apply/commit attempt still owns the newest | |
| 790 | + | // request. commit also advances the separately observable committed epoch. | |
| 791 | + | func (c *renderCoordinator) resizeCurrent(epoch uint64, source *attachedClient, commit bool) bool { | |
| 792 | + | c.mu.Lock() | |
| 793 | + | defer c.mu.Unlock() | |
| 794 | + | if c.torndown || c.attachment != source || c.resize.source != source || c.resize.epoch != epoch { | |
| 795 | + | return false | |
| 796 | + | } | |
| 797 | + | if commit { | |
| 798 | + | c.resize.committed = epoch | |
| 799 | + | } | |
| 800 | + | return true | |
| 801 | + | } | |
| 802 | + | ||
| 706 | 803 | // resizeSnapshot returns a locked copy of the latest resize metadata. | |
| 707 | 804 | func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata { | |
| 708 | 805 | c.mu.Lock() | |
| @@ -70,9 +70,10 @@ type renderWake struct { | |||||
| 70 | // metadata ownership only — S1/S2 dispatch stays with the retained PR #71 | 70 | // metadata ownership only — S1/S2 dispatch stays with the retained PR #71 | ||
| 71 | // attachment timer path. | 71 | // attachment timer path. | ||
| 72 | type resizeRequestMetadata struct { | 72 | type resizeRequestMetadata struct { | ||
| 73 | - | size domain.Size | 73 | + | size domain.Size |
| 74 | - | source *attachedClient | 74 | + | source *attachedClient |
| 75 | - | epoch uint64 | 75 | + | epoch uint64 // latest requested epoch |
| 76 | + | committed uint64 // latest published epoch | |||
| 76 | } | 77 | } | ||
| 77 | 78 | ||||
| 78 | // renderCoordinatorOptions wires one coordinator instance. | 79 | // renderCoordinatorOptions wires one coordinator instance. | ||
| @@ -158,8 +159,13 @@ type renderCoordinator struct { | |||||
| 158 | detached bool | 159 | detached bool | ||
| 159 | torndown bool | 160 | torndown bool | ||
| 160 | 161 | ||||
| 161 | - | resize resizeRequestMetadata | 162 | + | resize resizeRequestMetadata |
| 162 | - | metrics renderCoordinatorBurstMetrics | 163 | + | // resizeTimer is independent from render invalidation deadlines. It owns |
| 164 | + | // resize burst coalescing and is cancelled by the same lifecycle changes. | |||
| 165 | + | resizeTimer ports.Timer | |||
| 166 | + | resizeCancel chan struct{} | |||
| 167 | + | resizeGen uint64 | |||
| 168 | + | metrics renderCoordinatorBurstMetrics | |||
| 163 | // generation invalidates callbacks from superseded deadline/watchdog timers. | 169 | // generation invalidates callbacks from superseded deadline/watchdog timers. | ||
| 164 | // Each worker also owns an explicit cancellation channel: fake timers may | 170 | // Each worker also owns an explicit cancellation channel: fake timers may | ||
| 165 | // expose a nil or never-firing C channel, so Stop alone cannot release a | 171 | // expose a nil or never-firing C channel, so Stop alone cannot release a | ||
| @@ -609,7 +615,7 @@ func (c *renderCoordinator) wakeCurrent(w renderWake) bool { | |||||
| 609 | // attachment. | 615 | // attachment. | ||
| 610 | func (c *renderCoordinator) noteDetach(ac *attachedClient) { | 616 | func (c *renderCoordinator) noteDetach(ac *attachedClient) { | ||
| 611 | c.mu.Lock() | 617 | c.mu.Lock() | ||
| 612 | - | var timer ports.Timer | 618 | + | var timer, resizeTimer ports.Timer |
| 613 | if c.attachment == ac { | 619 | if c.attachment == ac { | ||
| 614 | c.advanceAttachmentEpochLocked(ac) | 620 | c.advanceAttachmentEpochLocked(ac) | ||
| 615 | c.attachment = nil | 621 | c.attachment = nil | ||
| @@ -624,9 +630,15 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) { | |||||
| 624 | c.generation++ | 630 | c.generation++ | ||
| 625 | c.armed = false | 631 | c.armed = false | ||
| 626 | timer = c.detachNormalTimerLocked() | 632 | timer = c.detachNormalTimerLocked() | ||
| 633 | + | if c.resizeCancel != nil { | |||
| 634 | + | close(c.resizeCancel) | |||
| 635 | + | c.resizeCancel = nil | |||
| 636 | + | } | |||
| 637 | + | resizeTimer, c.resizeTimer = c.resizeTimer, nil | |||
| 627 | } | 638 | } | ||
| 628 | c.mu.Unlock() | 639 | c.mu.Unlock() | ||
| 629 | stopTimer(timer) | 640 | stopTimer(timer) | ||
| 641 | + | stopTimer(resizeTimer) | |||
| 630 | } | 642 | } | ||
| 631 | 643 | ||||
| 632 | // noteReplace hands the coordinator from old to replacement; callbacks | 644 | // noteReplace hands the coordinator from old to replacement; callbacks | ||
| @@ -641,7 +653,7 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin | |||||
| 641 | // which predated coordinator ownership. In that case nil has no pending | 653 | // which predated coordinator ownership. In that case nil has no pending | ||
| 642 | // identity to invalidate, and the replacement becomes the first bound | 654 | // identity to invalidate, and the replacement becomes the first bound | ||
| 643 | // attachment atomically with this lifecycle transition. | 655 | // attachment atomically with this lifecycle transition. | ||
| 644 | - | var timer ports.Timer | 656 | + | var timer, resizeTimer ports.Timer |
| 645 | if c.attachment == old || c.attachment == nil { | 657 | if c.attachment == old || c.attachment == nil { | ||
| 646 | c.advanceAttachmentEpochLocked(old, replacement) | 658 | c.advanceAttachmentEpochLocked(old, replacement) | ||
| 647 | c.attachment = replacement | 659 | c.attachment = replacement | ||
| @@ -659,9 +671,15 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin | |||||
| 659 | c.generation++ | 671 | c.generation++ | ||
| 660 | c.armed = false | 672 | c.armed = false | ||
| 661 | timer = c.detachNormalTimerLocked() | 673 | timer = c.detachNormalTimerLocked() | ||
| 674 | + | if c.resizeCancel != nil { | |||
| 675 | + | close(c.resizeCancel) | |||
| 676 | + | c.resizeCancel = nil | |||
| 677 | + | } | |||
| 678 | + | resizeTimer, c.resizeTimer = c.resizeTimer, nil | |||
| 662 | } | 679 | } | ||
| 663 | c.mu.Unlock() | 680 | c.mu.Unlock() | ||
| 664 | stopTimer(timer) | 681 | stopTimer(timer) | ||
| 682 | + | stopTimer(resizeTimer) | |||
| 665 | } | 683 | } | ||
| 666 | 684 | ||||
| 667 | // notePark invalidates pending wakes when the attachment parks for resume. | 685 | // notePark invalidates pending wakes when the attachment parks for resume. | ||
| @@ -677,6 +695,12 @@ func (c *renderCoordinator) noteSessionTeardown() { | |||||
| 677 | c.previewWake = nil | 695 | c.previewWake = nil | ||
| 678 | c.previewWakes = nil | 696 | c.previewWakes = nil | ||
| 679 | c.pending = false | 697 | c.pending = false | ||
| 698 | + | if c.resizeCancel != nil { | |||
| 699 | + | close(c.resizeCancel) | |||
| 700 | + | c.resizeCancel = nil | |||
| 701 | + | } | |||
| 702 | + | resizeTimer := c.resizeTimer | |||
| 703 | + | c.resizeTimer = nil | |||
| 680 | c.ackDeferred = false | 704 | c.ackDeferred = false | ||
| 681 | c.pendingPreview = false | 705 | c.pendingPreview = false | ||
| 682 | c.generation++ | 706 | c.generation++ | ||
| @@ -685,6 +709,7 @@ func (c *renderCoordinator) noteSessionTeardown() { | |||||
| 685 | timers := c.detachSyncTimersLocked() | 709 | timers := c.detachSyncTimersLocked() | ||
| 686 | c.mu.Unlock() | 710 | c.mu.Unlock() | ||
| 687 | stopTimer(timer) | 711 | stopTimer(timer) | ||
| 712 | + | stopTimer(resizeTimer) | |||
| 688 | stopTimers(timers) | 713 | stopTimers(timers) | ||
| 689 | } | 714 | } | ||
| 690 | 715 | ||||
| @@ -703,6 +728,78 @@ func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attach | |||||
| 703 | return c.resize.epoch | 728 | return c.resize.epoch | ||
| 704 | } | 729 | } | ||
| 705 | 730 | ||||
| 731 | + | // scheduleResize records a latest-wins request and runs apply after the bounded | |||
| 732 | + | // bulk window. The callback never runs with c.mu held. | |||
| 733 | + | func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedClient, run func(uint64)) uint64 { | |||
| 734 | + | epoch := c.recordResizeRequest(size, source) | |||
| 735 | + | if epoch == 0 { | |||
| 736 | + | return 0 | |||
| 737 | + | } | |||
| 738 | + | c.mu.Lock() | |||
| 739 | + | old := c.resizeTimer | |||
| 740 | + | if c.resizeCancel != nil { | |||
| 741 | + | close(c.resizeCancel) | |||
| 742 | + | } | |||
| 743 | + | c.resizeGen++ | |||
| 744 | + | gen := c.resizeGen | |||
| 745 | + | cancel := make(chan struct{}) | |||
| 746 | + | c.resizeCancel = cancel | |||
| 747 | + | clock := c.opts.clock | |||
| 748 | + | c.resizeTimer = nil | |||
| 749 | + | c.mu.Unlock() | |||
| 750 | + | stopTimer(old) | |||
| 751 | + | if clock == nil { | |||
| 752 | + | run(epoch) | |||
| 753 | + | return epoch | |||
| 754 | + | } | |||
| 755 | + | timer := clock.NewTimer(minOutputRenderDeadline) | |||
| 756 | + | if timer.C() == nil { | |||
| 757 | + | stopTimer(timer) | |||
| 758 | + | run(epoch) | |||
| 759 | + | return epoch | |||
| 760 | + | } | |||
| 761 | + | c.mu.Lock() | |||
| 762 | + | if c.resizeGen != gen || c.torndown { | |||
| 763 | + | c.mu.Unlock() | |||
| 764 | + | stopTimer(timer) | |||
| 765 | + | return epoch | |||
| 766 | + | } | |||
| 767 | + | c.resizeTimer = timer | |||
| 768 | + | c.mu.Unlock() | |||
| 769 | + | go func() { | |||
| 770 | + | select { | |||
| 771 | + | case <-timer.C(): | |||
| 772 | + | case <-cancel: | |||
| 773 | + | return | |||
| 774 | + | } | |||
| 775 | + | c.mu.Lock() | |||
| 776 | + | valid := !c.torndown && c.resizeGen == gen && c.resize.epoch == epoch && c.resize.source == source | |||
| 777 | + | c.resizeTimer = nil | |||
| 778 | + | if c.resizeGen == gen { | |||
| 779 | + | c.resizeCancel = nil | |||
| 780 | + | } | |||
| 781 | + | c.mu.Unlock() | |||
| 782 | + | if valid { | |||
| 783 | + | run(epoch) | |||
| 784 | + | } | |||
| 785 | + | }() | |||
| 786 | + | return epoch | |||
| 787 | + | } | |||
| 788 | + | ||||
| 789 | + | // resizeCurrent verifies that an apply/commit attempt still owns the newest | |||
| 790 | + | // request. commit also advances the separately observable committed epoch. | |||
| 791 | + | func (c *renderCoordinator) resizeCurrent(epoch uint64, source *attachedClient, commit bool) bool { | |||
| 792 | + | c.mu.Lock() | |||
| 793 | + | defer c.mu.Unlock() | |||
| 794 | + | if c.torndown || c.attachment != source || c.resize.source != source || c.resize.epoch != epoch { | |||
| 795 | + | return false | |||
| 796 | + | } | |||
| 797 | + | if commit { | |||
| 798 | + | c.resize.committed = epoch | |||
| 799 | + | } | |||
| 800 | + | return true | |||
| 801 | + | } | |||
| 802 | + | ||||
| 706 | // resizeSnapshot returns a locked copy of the latest resize metadata. | 803 | // resizeSnapshot returns a locked copy of the latest resize metadata. | ||
| 707 | func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata { | 804 | func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata { | ||
| 708 | c.mu.Lock() | 805 | c.mu.Lock() | ||
A internal/usecase/daemon/transactional_resize.go +153 -0
| @@ -0,0 +1,153 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // resizeMember is an unpublished layout decision. Prepare only reads guarded | |
| 9 | + | // state; apply performs the external PTY call; commit is the sole publisher. | |
| 10 | + | type resizeMember struct { | |
| 11 | + | tab *tab | |
| 12 | + | pane *pane | |
| 13 | + | rect domain.Rect | |
| 14 | + | floating floatingGeometry | |
| 15 | + | isFloating bool | |
| 16 | + | ok bool | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | type resizePlan struct { | |
| 20 | + | size domain.Size | |
| 21 | + | tabs []*tab | |
| 22 | + | members []resizeMember | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan { | |
| 26 | + | plan := resizePlan{size: size} | |
| 27 | + | sess.mu.Lock() | |
| 28 | + | plan.tabs = append(plan.tabs, sess.tabs...) | |
| 29 | + | active := sess.active | |
| 30 | + | sess.mu.Unlock() | |
| 31 | + | for i, tb := range plan.tabs { | |
| 32 | + | tb.mu.Lock() | |
| 33 | + | if tb.tree != nil && tb.tree.Root != nil { | |
| 34 | + | ts := tabSize(size) | |
| 35 | + | area := domain.Rect{Width: ts.Cols, Height: ts.Rows} | |
| 36 | + | if placements, ok := layout.Solve(tb.tree.Root, area); ok { | |
| 37 | + | for _, pl := range placements { | |
| 38 | + | if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 { | |
| 39 | + | if p := tb.panes[pl.ID]; p != nil { | |
| 40 | + | plan.members = append(plan.members, resizeMember{tab: tb, pane: p, rect: pl.Content}) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | if i == active && tb.floating.state == floatingVisible && tb.floating.pane != nil { | |
| 47 | + | g := calculateContentFloatingGeometry(tabSize(size), d.currentFloatingConfig()) | |
| 48 | + | if g.valid() { | |
| 49 | + | plan.members = append(plan.members, resizeMember{tab: tb, pane: tb.floating.pane, rect: g.Inner, floating: g, isFloating: true}) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | tb.mu.Unlock() | |
| 53 | + | } | |
| 54 | + | return plan | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // applyResize deliberately holds only a pane's resizeMu around its PTY call; | |
| 58 | + | // no daemon, session, tab, or pane lock crosses the external boundary. | |
| 59 | + | func (d *Daemon) applyResize(plan *resizePlan) { | |
| 60 | + | for i := range plan.members { | |
| 61 | + | m := &plan.members[i] | |
| 62 | + | m.pane.resizeMu.Lock() | |
| 63 | + | m.pane.mu.Lock() | |
| 64 | + | old, pty := m.pane.rect, m.pane.pty | |
| 65 | + | m.pane.mu.Unlock() | |
| 66 | + | if old.Width == m.rect.Width && old.Height == m.rect.Height { | |
| 67 | + | m.ok = true | |
| 68 | + | } else if pty == nil { | |
| 69 | + | m.ok = true | |
| 70 | + | } else if err := pty.Resize(rectSize(m.rect)); err != nil { | |
| 71 | + | d.log.Warn("pty resize failed", "err", err) | |
| 72 | + | } else { | |
| 73 | + | m.ok = true | |
| 74 | + | } | |
| 75 | + | m.pane.resizeMu.Unlock() | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan) { | |
| 80 | + | // Publish tab layout and every rectangle together only after the coordinator | |
| 81 | + | // has rejected obsolete work. A later request may have applied already, but | |
| 82 | + | // it can never publish through this epoch. | |
| 83 | + | for _, tb := range plan.tabs { | |
| 84 | + | tb.mu.Lock() | |
| 85 | + | tb.size = tabSize(plan.size) | |
| 86 | + | tb.mu.Unlock() | |
| 87 | + | } | |
| 88 | + | for _, m := range plan.members { | |
| 89 | + | m.pane.mu.Lock() | |
| 90 | + | m.pane.rect = m.rect | |
| 91 | + | if m.isFloating { | |
| 92 | + | m.pane.popupGeometry = m.floating | |
| 93 | + | } | |
| 94 | + | if m.ok { | |
| 95 | + | m.pane.screen.Resize(m.rect.Width, m.rect.Height) | |
| 96 | + | } | |
| 97 | + | m.pane.mu.Unlock() | |
| 98 | + | } | |
| 99 | + | if ac != nil { | |
| 100 | + | ac.sendMu.Lock() | |
| 101 | + | ac.size = plan.size | |
| 102 | + | ac.sendMu.Unlock() | |
| 103 | + | } | |
| 104 | + | markSnapshotDirty(sess) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) { | |
| 108 | + | rc := sess.renderCoordinator() | |
| 109 | + | if rc == nil { | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | snap := rc.resizeSnapshot() | |
| 113 | + | if !rc.resizeCurrent(epoch, ac, false) { | |
| 114 | + | return | |
| 115 | + | } | |
| 116 | + | d.exitCopyMode(ac) | |
| 117 | + | plan := d.prepareResize(sess, snap.size) | |
| 118 | + | if !rc.resizeCurrent(epoch, ac, false) { | |
| 119 | + | return | |
| 120 | + | } | |
| 121 | + | d.applyResize(&plan) | |
| 122 | + | if !rc.resizeCurrent(epoch, ac, true) { | |
| 123 | + | return | |
| 124 | + | } | |
| 125 | + | d.commitResize(sess, ac, plan) | |
| 126 | + | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 127 | + | // A successful transaction publishes exactly one full S2 frame. The | |
| 128 | + | // coordinator is the only emission route and stale epochs never reach it. | |
| 129 | + | rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) { | |
| 133 | + | if sess == nil || !size.Valid() { | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | if ac == nil { | |
| 137 | + | // Headless geometry has no coordinator/transport to coalesce, but keeps | |
| 138 | + | // the same prepare/apply/commit ordering. | |
| 139 | + | plan := d.prepareResize(sess, size) | |
| 140 | + | d.applyResize(&plan) | |
| 141 | + | d.commitResize(sess, nil, plan) | |
| 142 | + | return | |
| 143 | + | } | |
| 144 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 145 | + | if immediate { | |
| 146 | + | epoch := rc.recordResizeRequest(size, ac) | |
| 147 | + | if epoch != 0 { | |
| 148 | + | d.runResizeTransaction(sess, ac, epoch) | |
| 149 | + | } | |
| 150 | + | return | |
| 151 | + | } | |
| 152 | + | rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) }) | |
| 153 | + | } | |
| @@ -0,0 +1,153 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // resizeMember is an unpublished layout decision. Prepare only reads guarded | |||
| 9 | + | // state; apply performs the external PTY call; commit is the sole publisher. | |||
| 10 | + | type resizeMember struct { | |||
| 11 | + | tab *tab | |||
| 12 | + | pane *pane | |||
| 13 | + | rect domain.Rect | |||
| 14 | + | floating floatingGeometry | |||
| 15 | + | isFloating bool | |||
| 16 | + | ok bool | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | type resizePlan struct { | |||
| 20 | + | size domain.Size | |||
| 21 | + | tabs []*tab | |||
| 22 | + | members []resizeMember | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan { | |||
| 26 | + | plan := resizePlan{size: size} | |||
| 27 | + | sess.mu.Lock() | |||
| 28 | + | plan.tabs = append(plan.tabs, sess.tabs...) | |||
| 29 | + | active := sess.active | |||
| 30 | + | sess.mu.Unlock() | |||
| 31 | + | for i, tb := range plan.tabs { | |||
| 32 | + | tb.mu.Lock() | |||
| 33 | + | if tb.tree != nil && tb.tree.Root != nil { | |||
| 34 | + | ts := tabSize(size) | |||
| 35 | + | area := domain.Rect{Width: ts.Cols, Height: ts.Rows} | |||
| 36 | + | if placements, ok := layout.Solve(tb.tree.Root, area); ok { | |||
| 37 | + | for _, pl := range placements { | |||
| 38 | + | if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 { | |||
| 39 | + | if p := tb.panes[pl.ID]; p != nil { | |||
| 40 | + | plan.members = append(plan.members, resizeMember{tab: tb, pane: p, rect: pl.Content}) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | if i == active && tb.floating.state == floatingVisible && tb.floating.pane != nil { | |||
| 47 | + | g := calculateContentFloatingGeometry(tabSize(size), d.currentFloatingConfig()) | |||
| 48 | + | if g.valid() { | |||
| 49 | + | plan.members = append(plan.members, resizeMember{tab: tb, pane: tb.floating.pane, rect: g.Inner, floating: g, isFloating: true}) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | tb.mu.Unlock() | |||
| 53 | + | } | |||
| 54 | + | return plan | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // applyResize deliberately holds only a pane's resizeMu around its PTY call; | |||
| 58 | + | // no daemon, session, tab, or pane lock crosses the external boundary. | |||
| 59 | + | func (d *Daemon) applyResize(plan *resizePlan) { | |||
| 60 | + | for i := range plan.members { | |||
| 61 | + | m := &plan.members[i] | |||
| 62 | + | m.pane.resizeMu.Lock() | |||
| 63 | + | m.pane.mu.Lock() | |||
| 64 | + | old, pty := m.pane.rect, m.pane.pty | |||
| 65 | + | m.pane.mu.Unlock() | |||
| 66 | + | if old.Width == m.rect.Width && old.Height == m.rect.Height { | |||
| 67 | + | m.ok = true | |||
| 68 | + | } else if pty == nil { | |||
| 69 | + | m.ok = true | |||
| 70 | + | } else if err := pty.Resize(rectSize(m.rect)); err != nil { | |||
| 71 | + | d.log.Warn("pty resize failed", "err", err) | |||
| 72 | + | } else { | |||
| 73 | + | m.ok = true | |||
| 74 | + | } | |||
| 75 | + | m.pane.resizeMu.Unlock() | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan) { | |||
| 80 | + | // Publish tab layout and every rectangle together only after the coordinator | |||
| 81 | + | // has rejected obsolete work. A later request may have applied already, but | |||
| 82 | + | // it can never publish through this epoch. | |||
| 83 | + | for _, tb := range plan.tabs { | |||
| 84 | + | tb.mu.Lock() | |||
| 85 | + | tb.size = tabSize(plan.size) | |||
| 86 | + | tb.mu.Unlock() | |||
| 87 | + | } | |||
| 88 | + | for _, m := range plan.members { | |||
| 89 | + | m.pane.mu.Lock() | |||
| 90 | + | m.pane.rect = m.rect | |||
| 91 | + | if m.isFloating { | |||
| 92 | + | m.pane.popupGeometry = m.floating | |||
| 93 | + | } | |||
| 94 | + | if m.ok { | |||
| 95 | + | m.pane.screen.Resize(m.rect.Width, m.rect.Height) | |||
| 96 | + | } | |||
| 97 | + | m.pane.mu.Unlock() | |||
| 98 | + | } | |||
| 99 | + | if ac != nil { | |||
| 100 | + | ac.sendMu.Lock() | |||
| 101 | + | ac.size = plan.size | |||
| 102 | + | ac.sendMu.Unlock() | |||
| 103 | + | } | |||
| 104 | + | markSnapshotDirty(sess) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch uint64) { | |||
| 108 | + | rc := sess.renderCoordinator() | |||
| 109 | + | if rc == nil { | |||
| 110 | + | return | |||
| 111 | + | } | |||
| 112 | + | snap := rc.resizeSnapshot() | |||
| 113 | + | if !rc.resizeCurrent(epoch, ac, false) { | |||
| 114 | + | return | |||
| 115 | + | } | |||
| 116 | + | d.exitCopyMode(ac) | |||
| 117 | + | plan := d.prepareResize(sess, snap.size) | |||
| 118 | + | if !rc.resizeCurrent(epoch, ac, false) { | |||
| 119 | + | return | |||
| 120 | + | } | |||
| 121 | + | d.applyResize(&plan) | |||
| 122 | + | if !rc.resizeCurrent(epoch, ac, true) { | |||
| 123 | + | return | |||
| 124 | + | } | |||
| 125 | + | d.commitResize(sess, ac, plan) | |||
| 126 | + | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |||
| 127 | + | // A successful transaction publishes exactly one full S2 frame. The | |||
| 128 | + | // coordinator is the only emission route and stale epochs never reach it. | |||
| 129 | + | rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) { | |||
| 133 | + | if sess == nil || !size.Valid() { | |||
| 134 | + | return | |||
| 135 | + | } | |||
| 136 | + | if ac == nil { | |||
| 137 | + | // Headless geometry has no coordinator/transport to coalesce, but keeps | |||
| 138 | + | // the same prepare/apply/commit ordering. | |||
| 139 | + | plan := d.prepareResize(sess, size) | |||
| 140 | + | d.applyResize(&plan) | |||
| 141 | + | d.commitResize(sess, nil, plan) | |||
| 142 | + | return | |||
| 143 | + | } | |||
| 144 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |||
| 145 | + | if immediate { | |||
| 146 | + | epoch := rc.recordResizeRequest(size, ac) | |||
| 147 | + | if epoch != 0 { | |||
| 148 | + | d.runResizeTransaction(sess, ac, epoch) | |||
| 149 | + | } | |||
| 150 | + | return | |||
| 151 | + | } | |||
| 152 | + | rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) }) | |||
| 153 | + | } | |||