fix(daemon): track sync lifecycle across visibility
- commit
- ddea50cbf6f6aeb94422e38c0e18ce71dc030f31
- author
- brice · · 2026-07-11 19:12 UTC
- parent
- eafd950
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +118 -33
M internal/usecase/daemon/render.go +37 -29
| @@ -30,45 +30,48 @@ func signal(ch chan struct{}) { | |||
| 30 | 30 | } | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | - | // clearNonRenderablePaneDamage is the single transitional S1 owner for VT | |
| 34 | - | // damage that cannot reach a composition target. S2 replaces this visibility | |
| 35 | - | // decision with pipeline ownership; do not paint or mutate renderer shadows | |
| 36 | - | // here. | |
| 37 | - | func (d *Daemon) clearNonRenderablePaneDamage(sess *session, tb *tab, p *pane) bool { | |
| 33 | + | // paneRenderable reports dynamic composition visibility. Callers must not | |
| 34 | + | // hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks. | |
| 35 | + | func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool { | |
| 38 | 36 | if sess == nil || tb == nil || p == nil { | |
| 39 | 37 | return false | |
| 40 | 38 | } | |
| 41 | - | ||
| 42 | - | // A picker may render an otherwise headless/inactive target tab into its | |
| 43 | - | // attached owner's preview. Keep that source damage until the target | |
| 44 | - | // coordinator has supplied the preview wake. | |
| 45 | 39 | preview := d.tabIsPickerPreview(tb) | |
| 46 | - | ||
| 47 | 40 | sess.mu.Lock() | |
| 48 | 41 | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | |
| 49 | 42 | attached := sess.client != nil | |
| 50 | 43 | sess.mu.Unlock() | |
| 51 | - | ||
| 52 | 44 | tb.mu.Lock() | |
| 53 | - | renderable := false | |
| 45 | + | defer tb.mu.Unlock() | |
| 54 | 46 | if tb.floating.pane == p { | |
| 55 | - | renderable = active && attached && tb.floating.state == floatingVisible | |
| 56 | - | } else if (active && attached) || preview { | |
| 57 | - | if placements, ok := solvedPlacementsLocked(tb); ok { | |
| 58 | - | for _, placement := range placements { | |
| 59 | - | if placement.ID == p.id && !placement.Collapsed && placement.Content.Width > 0 && placement.Content.Height > 0 { | |
| 60 | - | renderable = true | |
| 61 | - | break | |
| 62 | - | } | |
| 63 | - | } | |
| 47 | + | return active && attached && tb.floating.state == floatingVisible | |
| 48 | + | } | |
| 49 | + | if !((active && attached) || preview) { | |
| 50 | + | return false | |
| 51 | + | } | |
| 52 | + | placements, ok := solvedPlacementsLocked(tb) | |
| 53 | + | if !ok { | |
| 54 | + | return false | |
| 55 | + | } | |
| 56 | + | for _, placement := range placements { | |
| 57 | + | if placement.ID == p.id && !placement.Collapsed && placement.Content.Width > 0 && placement.Content.Height > 0 { | |
| 58 | + | return true | |
| 64 | 59 | } | |
| 65 | 60 | } | |
| 66 | - | if !renderable { | |
| 61 | + | return false | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // clearNonRenderablePaneDamage is the single transitional S1 owner for VT | |
| 65 | + | // damage that cannot reach a composition target. S2 replaces this visibility | |
| 66 | + | // decision with pipeline ownership; do not paint or mutate renderer shadows | |
| 67 | + | // here. | |
| 68 | + | func (d *Daemon) clearNonRenderablePaneDamage(sess *session, tb *tab, p *pane) bool { | |
| 69 | + | renderable := d.paneRenderable(sess, tb, p) | |
| 70 | + | if !renderable && p != nil { | |
| 67 | 71 | p.mu.Lock() | |
| 68 | 72 | p.screen.ClearDamage() | |
| 69 | 73 | p.mu.Unlock() | |
| 70 | 74 | } | |
| 71 | - | tb.mu.Unlock() | |
| 72 | 75 | return renderable | |
| 73 | 76 | } | |
| 74 | 77 | ||
| @@ -157,29 +160,34 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||
| 157 | 160 | } | |
| 158 | 161 | resp = resp[:0] | |
| 159 | 162 | } | |
| 160 | - | if rc := sess.renderCoordinator(); renderable && rc != nil { | |
| 163 | + | if rc := sess.renderCoordinator(); rc != nil { | |
| 161 | 164 | if wasSyncing != isSyncing { | |
| 162 | 165 | gen := syncGen | |
| 163 | 166 | if isSyncing { | |
| 164 | - | rc.noteSyncBegin(p, gen, func() { | |
| 167 | + | rc.noteSyncBeginWithRenderability(p, gen, func() bool { | |
| 168 | + | return d.paneRenderable(sess, tb, p) | |
| 169 | + | }, func() { | |
| 165 | 170 | p.mu.Lock() | |
| 166 | 171 | if p.syncGen == gen && p.screen.SyncUpdateActive() { | |
| 167 | 172 | p.screen.ForceSyncEnd() | |
| 168 | 173 | } | |
| 169 | 174 | p.mu.Unlock() | |
| 170 | 175 | }) | |
| 171 | - | // The accumulated synchronized batch is the pending render. | |
| 172 | - | rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |
| 176 | + | // The accumulated synchronized batch is the pending render only | |
| 177 | + | // while this pane can currently reach a composition target. | |
| 178 | + | if renderable { | |
| 179 | + | rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |
| 180 | + | } | |
| 173 | 181 | } else { | |
| 174 | 182 | rc.noteSyncEnd(p, gen) | |
| 175 | 183 | } | |
| 176 | 184 | } | |
| 177 | - | if completeSyncRead { | |
| 185 | + | if completeSyncRead && renderable { | |
| 178 | 186 | // The entire batch completed in this read. Its final screen state | |
| 179 | 187 | // is ready now, so bypass the bulk debounce without arming a | |
| 180 | 188 | // watchdog for a batch that no longer exists. | |
| 181 | 189 | rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"}) | |
| 182 | - | } else if !isSyncing && wasSyncing == isSyncing { | |
| 190 | + | } else if renderable && !isSyncing && wasSyncing == isSyncing { | |
| 183 | 191 | rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |
| 184 | 192 | } | |
| 185 | 193 | } | |
| @@ -30,45 +30,48 @@ func signal(ch chan struct{}) { | |||||
| 30 | } | 30 | } | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | - | // clearNonRenderablePaneDamage is the single transitional S1 owner for VT | 33 | + | // paneRenderable reports dynamic composition visibility. Callers must not |
| 34 | - | // damage that cannot reach a composition target. S2 replaces this visibility | 34 | + | // hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks. |
| 35 | - | // decision with pipeline ownership; do not paint or mutate renderer shadows | 35 | + | func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool { |
| 36 | - | // here. | |||
| 37 | - | func (d *Daemon) clearNonRenderablePaneDamage(sess *session, tb *tab, p *pane) bool { | |||
| 38 | if sess == nil || tb == nil || p == nil { | 36 | if sess == nil || tb == nil || p == nil { | ||
| 39 | return false | 37 | return false | ||
| 40 | } | 38 | } | ||
| 41 | - | ||||
| 42 | - | // A picker may render an otherwise headless/inactive target tab into its | |||
| 43 | - | // attached owner's preview. Keep that source damage until the target | |||
| 44 | - | // coordinator has supplied the preview wake. | |||
| 45 | preview := d.tabIsPickerPreview(tb) | 39 | preview := d.tabIsPickerPreview(tb) | ||
| 46 | - | ||||
| 47 | sess.mu.Lock() | 40 | sess.mu.Lock() | ||
| 48 | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | 41 | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | ||
| 49 | attached := sess.client != nil | 42 | attached := sess.client != nil | ||
| 50 | sess.mu.Unlock() | 43 | sess.mu.Unlock() | ||
| 51 | - | ||||
| 52 | tb.mu.Lock() | 44 | tb.mu.Lock() | ||
| 53 | - | renderable := false | 45 | + | defer tb.mu.Unlock() |
| 54 | if tb.floating.pane == p { | 46 | if tb.floating.pane == p { | ||
| 55 | - | renderable = active && attached && tb.floating.state == floatingVisible | 47 | + | return active && attached && tb.floating.state == floatingVisible |
| 56 | - | } else if (active && attached) || preview { | 48 | + | } |
| 57 | - | if placements, ok := solvedPlacementsLocked(tb); ok { | 49 | + | if !((active && attached) || preview) { |
| 58 | - | for _, placement := range placements { | 50 | + | return false |
| 59 | - | if placement.ID == p.id && !placement.Collapsed && placement.Content.Width > 0 && placement.Content.Height > 0 { | 51 | + | } |
| 60 | - | renderable = true | 52 | + | placements, ok := solvedPlacementsLocked(tb) |
| 61 | - | break | 53 | + | if !ok { |
| 62 | - | } | 54 | + | return false |
| 63 | - | } | 55 | + | } |
| 56 | + | for _, placement := range placements { | |||
| 57 | + | if placement.ID == p.id && !placement.Collapsed && placement.Content.Width > 0 && placement.Content.Height > 0 { | |||
| 58 | + | return true | |||
| 64 | } | 59 | } | ||
| 65 | } | 60 | } | ||
| 66 | - | if !renderable { | 61 | + | return false |
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // clearNonRenderablePaneDamage is the single transitional S1 owner for VT | |||
| 65 | + | // damage that cannot reach a composition target. S2 replaces this visibility | |||
| 66 | + | // decision with pipeline ownership; do not paint or mutate renderer shadows | |||
| 67 | + | // here. | |||
| 68 | + | func (d *Daemon) clearNonRenderablePaneDamage(sess *session, tb *tab, p *pane) bool { | |||
| 69 | + | renderable := d.paneRenderable(sess, tb, p) | |||
| 70 | + | if !renderable && p != nil { | |||
| 67 | p.mu.Lock() | 71 | p.mu.Lock() | ||
| 68 | p.screen.ClearDamage() | 72 | p.screen.ClearDamage() | ||
| 69 | p.mu.Unlock() | 73 | p.mu.Unlock() | ||
| 70 | } | 74 | } | ||
| 71 | - | tb.mu.Unlock() | |||
| 72 | return renderable | 75 | return renderable | ||
| 73 | } | 76 | } | ||
| 74 | 77 | ||||
| @@ -157,29 +160,34 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||||
| 157 | } | 160 | } | ||
| 158 | resp = resp[:0] | 161 | resp = resp[:0] | ||
| 159 | } | 162 | } | ||
| 160 | - | if rc := sess.renderCoordinator(); renderable && rc != nil { | 163 | + | if rc := sess.renderCoordinator(); rc != nil { |
| 161 | if wasSyncing != isSyncing { | 164 | if wasSyncing != isSyncing { | ||
| 162 | gen := syncGen | 165 | gen := syncGen | ||
| 163 | if isSyncing { | 166 | if isSyncing { | ||
| 164 | - | rc.noteSyncBegin(p, gen, func() { | 167 | + | rc.noteSyncBeginWithRenderability(p, gen, func() bool { |
| 168 | + | return d.paneRenderable(sess, tb, p) | |||
| 169 | + | }, func() { | |||
| 165 | p.mu.Lock() | 170 | p.mu.Lock() | ||
| 166 | if p.syncGen == gen && p.screen.SyncUpdateActive() { | 171 | if p.syncGen == gen && p.screen.SyncUpdateActive() { | ||
| 167 | p.screen.ForceSyncEnd() | 172 | p.screen.ForceSyncEnd() | ||
| 168 | } | 173 | } | ||
| 169 | p.mu.Unlock() | 174 | p.mu.Unlock() | ||
| 170 | }) | 175 | }) | ||
| 171 | - | // The accumulated synchronized batch is the pending render. | 176 | + | // The accumulated synchronized batch is the pending render only |
| 172 | - | rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | 177 | + | // while this pane can currently reach a composition target. |
| 178 | + | if renderable { | |||
| 179 | + | rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |||
| 180 | + | } | |||
| 173 | } else { | 181 | } else { | ||
| 174 | rc.noteSyncEnd(p, gen) | 182 | rc.noteSyncEnd(p, gen) | ||
| 175 | } | 183 | } | ||
| 176 | } | 184 | } | ||
| 177 | - | if completeSyncRead { | 185 | + | if completeSyncRead && renderable { |
| 178 | // The entire batch completed in this read. Its final screen state | 186 | // The entire batch completed in this read. Its final screen state | ||
| 179 | // is ready now, so bypass the bulk debounce without arming a | 187 | // is ready now, so bypass the bulk debounce without arming a | ||
| 180 | // watchdog for a batch that no longer exists. | 188 | // watchdog for a batch that no longer exists. | ||
| 181 | rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"}) | 189 | rc.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"}) | ||
| 182 | - | } else if !isSyncing && wasSyncing == isSyncing { | 190 | + | } else if renderable && !isSyncing && wasSyncing == isSyncing { |
| 183 | rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | 191 | rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | ||
| 184 | } | 192 | } | ||
| 185 | } | 193 | } | ||
M internal/usecase/daemon/render_coordinator.go +81 -4
| @@ -76,7 +76,11 @@ type renderCoordinatorOptions struct { | |||
| 76 | 76 | // ackReady reports whether the attachment may compose another output | |
| 77 | 77 | // state (the outputStateStream window has capacity). | |
| 78 | 78 | ackReady func() bool | |
| 79 | - | // syncActive reports whether a synchronized-output batch is open. | |
| 79 | + | // syncRenderable reports whether a pane's synchronized batch currently | |
| 80 | + | // contributes to the attached composition. It must not acquire c.mu. | |
| 81 | + | syncRenderable func(*pane) bool | |
| 82 | + | // syncActive is retained for existing test harness construction; per-pane | |
| 83 | + | // lifecycle and syncRenderable are the coordinator gate. | |
| 80 | 84 | syncActive func() bool | |
| 81 | 85 | // onInvalidate observes every published invalidation (test-visible hook). | |
| 82 | 86 | onInvalidate func(renderInvalidation) | |
| @@ -170,6 +174,9 @@ func (c *renderCoordinator) cancelNormalTimerLocked() { | |||
| 170 | 174 | ||
| 171 | 175 | type syncBatch struct { | |
| 172 | 176 | generation uint64 | |
| 177 | + | // renderable is evaluated outside c.mu: tab/session visibility locks may be | |
| 178 | + | // acquired by this predicate. | |
| 179 | + | renderable func() bool | |
| 173 | 180 | force func() | |
| 174 | 181 | timer ports.Timer | |
| 175 | 182 | cancel chan struct{} | |
| @@ -288,6 +295,16 @@ func (c *renderCoordinator) notifyAck() { | |||
| 288 | 295 | // noteSyncBegin records a synchronized-output batch for its stable pane and | |
| 289 | 296 | // arms that pane's watchdog. Overlapping pane batches are independent. | |
| 290 | 297 | func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func()) { | |
| 298 | + | var renderable func() bool | |
| 299 | + | if c.opts.syncRenderable != nil { | |
| 300 | + | renderable = func() bool { return c.opts.syncRenderable(p) } | |
| 301 | + | } | |
| 302 | + | c.noteSyncBeginWithRenderability(p, gen, renderable, force...) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // noteSyncBeginWithRenderability records lifecycle unconditionally while the | |
| 306 | + | // supplied predicate decides dynamically whether this batch gates composition. | |
| 307 | + | func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64, renderable func() bool, force ...func()) { | |
| 291 | 308 | c.mu.Lock() | |
| 292 | 309 | if c.torndown { | |
| 293 | 310 | c.mu.Unlock() | |
| @@ -299,7 +316,7 @@ func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func()) | |||
| 299 | 316 | if old := c.syncBatches[p]; old != nil { | |
| 300 | 317 | cancelSyncBatchLocked(old) | |
| 301 | 318 | } | |
| 302 | - | batch := &syncBatch{generation: gen} | |
| 319 | + | batch := &syncBatch{generation: gen, renderable: renderable} | |
| 303 | 320 | if len(force) != 0 { | |
| 304 | 321 | batch.force = force[0] | |
| 305 | 322 | } | |
| @@ -345,15 +362,27 @@ func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func()) | |||
| 345 | 362 | // noteSyncEnd records completion for exactly one pane batch. It only flushes | |
| 346 | 363 | // after the aggregate gate opens. | |
| 347 | 364 | func (c *renderCoordinator) noteSyncEnd(p *pane, gen uint64) { | |
| 365 | + | // Visibility may acquire session/tab locks, so sample it before c.mu and | |
| 366 | + | // then verify the batch identity under c.mu. | |
| 348 | 367 | c.mu.Lock() | |
| 349 | 368 | batch := c.syncBatches[p] | |
| 350 | 369 | if batch == nil || batch.generation != gen { | |
| 351 | 370 | c.mu.Unlock() | |
| 352 | 371 | return | |
| 353 | 372 | } | |
| 373 | + | renderable := batch.renderable | |
| 374 | + | c.mu.Unlock() | |
| 375 | + | wasRenderable := renderable == nil || renderable() | |
| 376 | + | ||
| 377 | + | c.mu.Lock() | |
| 378 | + | batch = c.syncBatches[p] | |
| 379 | + | if batch == nil || batch.generation != gen { | |
| 380 | + | c.mu.Unlock() | |
| 381 | + | return | |
| 382 | + | } | |
| 354 | 383 | delete(c.syncBatches, p) | |
| 355 | 384 | cancelSyncBatchLocked(batch) | |
| 356 | - | if len(c.syncBatches) == 0 && c.pending { | |
| 385 | + | if wasRenderable && len(c.syncBatches) == 0 && c.pending { | |
| 357 | 386 | c.pendingUrgent = true | |
| 358 | 387 | } | |
| 359 | 388 | c.mu.Unlock() | |
| @@ -552,6 +581,9 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) { | |||
| 552 | 581 | } | |
| 553 | 582 | ackReady := c.opts.ackReady | |
| 554 | 583 | c.mu.Unlock() | |
| 584 | + | if !c.syncGateOpen() { | |
| 585 | + | return | |
| 586 | + | } | |
| 555 | 587 | ||
| 556 | 588 | ready := ackReady == nil || ackReady() | |
| 557 | 589 | ||
| @@ -562,6 +594,15 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) { | |||
| 562 | 594 | c.mu.Unlock() | |
| 563 | 595 | return | |
| 564 | 596 | } | |
| 597 | + | c.mu.Unlock() | |
| 598 | + | if !c.syncGateOpen() { | |
| 599 | + | return | |
| 600 | + | } | |
| 601 | + | c.mu.Lock() | |
| 602 | + | if !c.fireValidLocked(gen, watchdog) { | |
| 603 | + | c.mu.Unlock() | |
| 604 | + | return | |
| 605 | + | } | |
| 565 | 606 | w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog} | |
| 566 | 607 | if !ready { | |
| 567 | 608 | if deadline { | |
| @@ -596,7 +637,43 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) { | |||
| 596 | 637 | } | |
| 597 | 638 | ||
| 598 | 639 | func (c *renderCoordinator) fireValidLocked(gen uint64, watchdog bool) bool { | |
| 599 | - | return !c.torndown && c.pending && (watchdog || gen == c.generation) && len(c.syncBatches) == 0 | |
| 640 | + | return !c.torndown && c.pending && (watchdog || gen == c.generation) | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | // syncGateOpen evaluates pane visibility without c.mu, then reacquires and | |
| 644 | + | // validates the complete batch identity set before accepting that result. | |
| 645 | + | func (c *renderCoordinator) syncGateOpen() bool { | |
| 646 | + | for { | |
| 647 | + | c.mu.Lock() | |
| 648 | + | batches := make(map[*pane]*syncBatch, len(c.syncBatches)) | |
| 649 | + | for p, batch := range c.syncBatches { | |
| 650 | + | batches[p] = batch | |
| 651 | + | } | |
| 652 | + | c.mu.Unlock() | |
| 653 | + | ||
| 654 | + | gated := false | |
| 655 | + | for _, batch := range batches { | |
| 656 | + | if batch.renderable == nil || batch.renderable() { | |
| 657 | + | gated = true | |
| 658 | + | break | |
| 659 | + | } | |
| 660 | + | } | |
| 661 | + | ||
| 662 | + | c.mu.Lock() | |
| 663 | + | unchanged := len(batches) == len(c.syncBatches) | |
| 664 | + | if unchanged { | |
| 665 | + | for p, batch := range batches { | |
| 666 | + | if c.syncBatches[p] != batch || c.syncBatches[p].generation != batch.generation { | |
| 667 | + | unchanged = false | |
| 668 | + | break | |
| 669 | + | } | |
| 670 | + | } | |
| 671 | + | } | |
| 672 | + | c.mu.Unlock() | |
| 673 | + | if unchanged { | |
| 674 | + | return !gated | |
| 675 | + | } | |
| 676 | + | } | |
| 600 | 677 | } | |
| 601 | 678 | ||
| 602 | 679 | // takePendingPreviewsLocked snapshots and consumes the preview delivery for | |
| @@ -76,7 +76,11 @@ type renderCoordinatorOptions struct { | |||||
| 76 | // ackReady reports whether the attachment may compose another output | 76 | // ackReady reports whether the attachment may compose another output | ||
| 77 | // state (the outputStateStream window has capacity). | 77 | // state (the outputStateStream window has capacity). | ||
| 78 | ackReady func() bool | 78 | ackReady func() bool | ||
| 79 | - | // syncActive reports whether a synchronized-output batch is open. | 79 | + | // syncRenderable reports whether a pane's synchronized batch currently |
| 80 | + | // contributes to the attached composition. It must not acquire c.mu. | |||
| 81 | + | syncRenderable func(*pane) bool | |||
| 82 | + | // syncActive is retained for existing test harness construction; per-pane | |||
| 83 | + | // lifecycle and syncRenderable are the coordinator gate. | |||
| 80 | syncActive func() bool | 84 | syncActive func() bool | ||
| 81 | // onInvalidate observes every published invalidation (test-visible hook). | 85 | // onInvalidate observes every published invalidation (test-visible hook). | ||
| 82 | onInvalidate func(renderInvalidation) | 86 | onInvalidate func(renderInvalidation) | ||
| @@ -170,6 +174,9 @@ func (c *renderCoordinator) cancelNormalTimerLocked() { | |||||
| 170 | 174 | ||||
| 171 | type syncBatch struct { | 175 | type syncBatch struct { | ||
| 172 | generation uint64 | 176 | generation uint64 | ||
| 177 | + | // renderable is evaluated outside c.mu: tab/session visibility locks may be | |||
| 178 | + | // acquired by this predicate. | |||
| 179 | + | renderable func() bool | |||
| 173 | force func() | 180 | force func() | ||
| 174 | timer ports.Timer | 181 | timer ports.Timer | ||
| 175 | cancel chan struct{} | 182 | cancel chan struct{} | ||
| @@ -288,6 +295,16 @@ func (c *renderCoordinator) notifyAck() { | |||||
| 288 | // noteSyncBegin records a synchronized-output batch for its stable pane and | 295 | // noteSyncBegin records a synchronized-output batch for its stable pane and | ||
| 289 | // arms that pane's watchdog. Overlapping pane batches are independent. | 296 | // arms that pane's watchdog. Overlapping pane batches are independent. | ||
| 290 | func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func()) { | 297 | func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func()) { | ||
| 298 | + | var renderable func() bool | |||
| 299 | + | if c.opts.syncRenderable != nil { | |||
| 300 | + | renderable = func() bool { return c.opts.syncRenderable(p) } | |||
| 301 | + | } | |||
| 302 | + | c.noteSyncBeginWithRenderability(p, gen, renderable, force...) | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | // noteSyncBeginWithRenderability records lifecycle unconditionally while the | |||
| 306 | + | // supplied predicate decides dynamically whether this batch gates composition. | |||
| 307 | + | func (c *renderCoordinator) noteSyncBeginWithRenderability(p *pane, gen uint64, renderable func() bool, force ...func()) { | |||
| 291 | c.mu.Lock() | 308 | c.mu.Lock() | ||
| 292 | if c.torndown { | 309 | if c.torndown { | ||
| 293 | c.mu.Unlock() | 310 | c.mu.Unlock() | ||
| @@ -299,7 +316,7 @@ func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func()) | |||||
| 299 | if old := c.syncBatches[p]; old != nil { | 316 | if old := c.syncBatches[p]; old != nil { | ||
| 300 | cancelSyncBatchLocked(old) | 317 | cancelSyncBatchLocked(old) | ||
| 301 | } | 318 | } | ||
| 302 | - | batch := &syncBatch{generation: gen} | 319 | + | batch := &syncBatch{generation: gen, renderable: renderable} |
| 303 | if len(force) != 0 { | 320 | if len(force) != 0 { | ||
| 304 | batch.force = force[0] | 321 | batch.force = force[0] | ||
| 305 | } | 322 | } | ||
| @@ -345,15 +362,27 @@ func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func()) | |||||
| 345 | // noteSyncEnd records completion for exactly one pane batch. It only flushes | 362 | // noteSyncEnd records completion for exactly one pane batch. It only flushes | ||
| 346 | // after the aggregate gate opens. | 363 | // after the aggregate gate opens. | ||
| 347 | func (c *renderCoordinator) noteSyncEnd(p *pane, gen uint64) { | 364 | func (c *renderCoordinator) noteSyncEnd(p *pane, gen uint64) { | ||
| 365 | + | // Visibility may acquire session/tab locks, so sample it before c.mu and | |||
| 366 | + | // then verify the batch identity under c.mu. | |||
| 348 | c.mu.Lock() | 367 | c.mu.Lock() | ||
| 349 | batch := c.syncBatches[p] | 368 | batch := c.syncBatches[p] | ||
| 350 | if batch == nil || batch.generation != gen { | 369 | if batch == nil || batch.generation != gen { | ||
| 351 | c.mu.Unlock() | 370 | c.mu.Unlock() | ||
| 352 | return | 371 | return | ||
| 353 | } | 372 | } | ||
| 373 | + | renderable := batch.renderable | |||
| 374 | + | c.mu.Unlock() | |||
| 375 | + | wasRenderable := renderable == nil || renderable() | |||
| 376 | + | ||||
| 377 | + | c.mu.Lock() | |||
| 378 | + | batch = c.syncBatches[p] | |||
| 379 | + | if batch == nil || batch.generation != gen { | |||
| 380 | + | c.mu.Unlock() | |||
| 381 | + | return | |||
| 382 | + | } | |||
| 354 | delete(c.syncBatches, p) | 383 | delete(c.syncBatches, p) | ||
| 355 | cancelSyncBatchLocked(batch) | 384 | cancelSyncBatchLocked(batch) | ||
| 356 | - | if len(c.syncBatches) == 0 && c.pending { | 385 | + | if wasRenderable && len(c.syncBatches) == 0 && c.pending { |
| 357 | c.pendingUrgent = true | 386 | c.pendingUrgent = true | ||
| 358 | } | 387 | } | ||
| 359 | c.mu.Unlock() | 388 | c.mu.Unlock() | ||
| @@ -552,6 +581,9 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) { | |||||
| 552 | } | 581 | } | ||
| 553 | ackReady := c.opts.ackReady | 582 | ackReady := c.opts.ackReady | ||
| 554 | c.mu.Unlock() | 583 | c.mu.Unlock() | ||
| 584 | + | if !c.syncGateOpen() { | |||
| 585 | + | return | |||
| 586 | + | } | |||
| 555 | 587 | ||||
| 556 | ready := ackReady == nil || ackReady() | 588 | ready := ackReady == nil || ackReady() | ||
| 557 | 589 | ||||
| @@ -562,6 +594,15 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) { | |||||
| 562 | c.mu.Unlock() | 594 | c.mu.Unlock() | ||
| 563 | return | 595 | return | ||
| 564 | } | 596 | } | ||
| 597 | + | c.mu.Unlock() | |||
| 598 | + | if !c.syncGateOpen() { | |||
| 599 | + | return | |||
| 600 | + | } | |||
| 601 | + | c.mu.Lock() | |||
| 602 | + | if !c.fireValidLocked(gen, watchdog) { | |||
| 603 | + | c.mu.Unlock() | |||
| 604 | + | return | |||
| 605 | + | } | |||
| 565 | w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog} | 606 | w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog} | ||
| 566 | if !ready { | 607 | if !ready { | ||
| 567 | if deadline { | 608 | if deadline { | ||
| @@ -596,7 +637,43 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) { | |||||
| 596 | } | 637 | } | ||
| 597 | 638 | ||||
| 598 | func (c *renderCoordinator) fireValidLocked(gen uint64, watchdog bool) bool { | 639 | func (c *renderCoordinator) fireValidLocked(gen uint64, watchdog bool) bool { | ||
| 599 | - | return !c.torndown && c.pending && (watchdog || gen == c.generation) && len(c.syncBatches) == 0 | 640 | + | return !c.torndown && c.pending && (watchdog || gen == c.generation) |
| 641 | + | } | |||
| 642 | + | ||||
| 643 | + | // syncGateOpen evaluates pane visibility without c.mu, then reacquires and | |||
| 644 | + | // validates the complete batch identity set before accepting that result. | |||
| 645 | + | func (c *renderCoordinator) syncGateOpen() bool { | |||
| 646 | + | for { | |||
| 647 | + | c.mu.Lock() | |||
| 648 | + | batches := make(map[*pane]*syncBatch, len(c.syncBatches)) | |||
| 649 | + | for p, batch := range c.syncBatches { | |||
| 650 | + | batches[p] = batch | |||
| 651 | + | } | |||
| 652 | + | c.mu.Unlock() | |||
| 653 | + | ||||
| 654 | + | gated := false | |||
| 655 | + | for _, batch := range batches { | |||
| 656 | + | if batch.renderable == nil || batch.renderable() { | |||
| 657 | + | gated = true | |||
| 658 | + | break | |||
| 659 | + | } | |||
| 660 | + | } | |||
| 661 | + | ||||
| 662 | + | c.mu.Lock() | |||
| 663 | + | unchanged := len(batches) == len(c.syncBatches) | |||
| 664 | + | if unchanged { | |||
| 665 | + | for p, batch := range batches { | |||
| 666 | + | if c.syncBatches[p] != batch || c.syncBatches[p].generation != batch.generation { | |||
| 667 | + | unchanged = false | |||
| 668 | + | break | |||
| 669 | + | } | |||
| 670 | + | } | |||
| 671 | + | } | |||
| 672 | + | c.mu.Unlock() | |||
| 673 | + | if unchanged { | |||
| 674 | + | return !gated | |||
| 675 | + | } | |||
| 676 | + | } | |||
| 600 | } | 677 | } | ||
| 601 | 678 | ||||
| 602 | // takePendingPreviewsLocked snapshots and consumes the preview delivery for | 679 | // takePendingPreviewsLocked snapshots and consumes the preview delivery for | ||