fix(daemon): coordinate synchronized pane batches
- commit
- 0d938b0a6abbaf752186ca0f3f26ef5849c91fa7
- author
- brice · · 2026-07-11 18:44 UTC
- parent
- 6ad62fa
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +132 -61
M internal/usecase/daemon/pane_actions.go +3 -0
| @@ -257,6 +257,9 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 257 | 257 | ac.overlays.clearCopyModeForPane(p) | |
| 258 | 258 | } | |
| 259 | 259 | ||
| 260 | + | if rc := sess.renderCoordinator(); rc != nil { | |
| 261 | + | rc.noteSyncPaneRemoved(p) | |
| 262 | + | } | |
| 260 | 263 | if p.cancel != nil { | |
| 261 | 264 | p.cancel() | |
| 262 | 265 | } | |
| @@ -257,6 +257,9 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||||
| 257 | ac.overlays.clearCopyModeForPane(p) | 257 | ac.overlays.clearCopyModeForPane(p) | ||
| 258 | } | 258 | } | ||
| 259 | 259 | ||||
| 260 | + | if rc := sess.renderCoordinator(); rc != nil { | |||
| 261 | + | rc.noteSyncPaneRemoved(p) | |||
| 262 | + | } | |||
| 260 | if p.cancel != nil { | 263 | if p.cancel != nil { | ||
| 261 | p.cancel() | 264 | p.cancel() | ||
| 262 | } | 265 | } | ||
M internal/usecase/daemon/render.go +2 -2
| @@ -161,7 +161,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||
| 161 | 161 | if wasSyncing != isSyncing { | |
| 162 | 162 | gen := syncGen | |
| 163 | 163 | if isSyncing { | |
| 164 | - | rc.noteSyncBegin(gen, func() { | |
| 164 | + | rc.noteSyncBegin(p, gen, func() { | |
| 165 | 165 | p.mu.Lock() | |
| 166 | 166 | if p.syncGen == gen && p.screen.SyncUpdateActive() { | |
| 167 | 167 | p.screen.ForceSyncEnd() | |
| @@ -171,7 +171,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||
| 171 | 171 | // The accumulated synchronized batch is the pending render. | |
| 172 | 172 | rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |
| 173 | 173 | } else { | |
| 174 | - | rc.noteSyncEnd(gen) | |
| 174 | + | rc.noteSyncEnd(p, gen) | |
| 175 | 175 | } | |
| 176 | 176 | } | |
| 177 | 177 | if completeSyncRead { | |
| @@ -161,7 +161,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||||
| 161 | if wasSyncing != isSyncing { | 161 | if wasSyncing != isSyncing { | ||
| 162 | gen := syncGen | 162 | gen := syncGen | ||
| 163 | if isSyncing { | 163 | if isSyncing { | ||
| 164 | - | rc.noteSyncBegin(gen, func() { | 164 | + | rc.noteSyncBegin(p, gen, func() { |
| 165 | p.mu.Lock() | 165 | p.mu.Lock() | ||
| 166 | if p.syncGen == gen && p.screen.SyncUpdateActive() { | 166 | if p.syncGen == gen && p.screen.SyncUpdateActive() { | ||
| 167 | p.screen.ForceSyncEnd() | 167 | p.screen.ForceSyncEnd() | ||
| @@ -171,7 +171,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||||
| 171 | // The accumulated synchronized batch is the pending render. | 171 | // The accumulated synchronized batch is the pending render. | ||
| 172 | rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | 172 | rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | ||
| 173 | } else { | 173 | } else { | ||
| 174 | - | rc.noteSyncEnd(gen) | 174 | + | rc.noteSyncEnd(p, gen) |
| 175 | } | 175 | } | ||
| 176 | } | 176 | } | ||
| 177 | if completeSyncRead { | 177 | if completeSyncRead { | ||
M internal/usecase/daemon/render_coordinator.go +75 -42
| @@ -141,11 +141,9 @@ type renderCoordinator struct { | |||
| 141 | 141 | normalTimer ports.Timer | |
| 142 | 142 | normalCancel chan struct{} | |
| 143 | 143 | normalWorkerDone chan struct{} | |
| 144 | - | syncGeneration uint64 | |
| 145 | - | syncWatchdog func() | |
| 146 | - | syncTimer ports.Timer | |
| 147 | - | syncCancel chan struct{} | |
| 148 | - | syncWorkerDone chan struct{} | |
| 144 | + | // syncBatches is keyed by the stable pane owner. Each pane owns an | |
| 145 | + | // independent watchdog; composition remains gated until every batch ends. | |
| 146 | + | syncBatches map[*pane]*syncBatch | |
| 149 | 147 | } | |
| 150 | 148 | ||
| 151 | 149 | // newRenderCoordinator constructs a coordinator bound to opts. It starts no | |
| @@ -167,14 +165,29 @@ func (c *renderCoordinator) cancelNormalTimerLocked() { | |||
| 167 | 165 | } | |
| 168 | 166 | } | |
| 169 | 167 | ||
| 170 | - | func (c *renderCoordinator) cancelSyncTimerLocked() { | |
| 171 | - | if c.syncTimer != nil { | |
| 172 | - | c.syncTimer.Stop() | |
| 173 | - | c.syncTimer = nil | |
| 168 | + | type syncBatch struct { | |
| 169 | + | generation uint64 | |
| 170 | + | force func() | |
| 171 | + | timer ports.Timer | |
| 172 | + | cancel chan struct{} | |
| 173 | + | done chan struct{} | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func cancelSyncBatchLocked(batch *syncBatch) { | |
| 177 | + | if batch.timer != nil { | |
| 178 | + | batch.timer.Stop() | |
| 179 | + | batch.timer = nil | |
| 174 | 180 | } | |
| 175 | - | if c.syncCancel != nil { | |
| 176 | - | close(c.syncCancel) | |
| 177 | - | c.syncCancel = nil | |
| 181 | + | if batch.cancel != nil { | |
| 182 | + | close(batch.cancel) | |
| 183 | + | batch.cancel = nil | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func (c *renderCoordinator) cancelSyncTimersLocked() { | |
| 188 | + | for pane, batch := range c.syncBatches { | |
| 189 | + | cancelSyncBatchLocked(batch) | |
| 190 | + | delete(c.syncBatches, pane) | |
| 178 | 191 | } | |
| 179 | 192 | } | |
| 180 | 193 | ||
| @@ -254,30 +267,34 @@ func (c *renderCoordinator) notifyAck() { | |||
| 254 | 267 | c.fire(gen, false) | |
| 255 | 268 | } | |
| 256 | 269 | ||
| 257 | - | // noteSyncBegin records that a synchronized-output batch opened for gen and | |
| 258 | - | // arms the completion watchdog. force is supplied by the pane owner so the | |
| 259 | - | // coordinator can end a wedged VT batch before it composes the pending state. | |
| 260 | - | func (c *renderCoordinator) noteSyncBegin(gen uint64, force ...func()) { | |
| 270 | + | // noteSyncBegin records a synchronized-output batch for its stable pane and | |
| 271 | + | // arms that pane's watchdog. Overlapping pane batches are independent. | |
| 272 | + | func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func()) { | |
| 261 | 273 | c.mu.Lock() | |
| 262 | 274 | if c.torndown { | |
| 263 | 275 | c.mu.Unlock() | |
| 264 | 276 | return | |
| 265 | 277 | } | |
| 266 | - | c.cancelSyncTimerLocked() | |
| 267 | - | c.syncGeneration = gen | |
| 268 | - | c.syncWatchdog = nil | |
| 278 | + | if c.syncBatches == nil { | |
| 279 | + | c.syncBatches = make(map[*pane]*syncBatch) | |
| 280 | + | } | |
| 281 | + | if old := c.syncBatches[p]; old != nil { | |
| 282 | + | cancelSyncBatchLocked(old) | |
| 283 | + | } | |
| 284 | + | batch := &syncBatch{generation: gen} | |
| 269 | 285 | if len(force) != 0 { | |
| 270 | - | c.syncWatchdog = force[0] | |
| 286 | + | batch.force = force[0] | |
| 271 | 287 | } | |
| 288 | + | c.syncBatches[p] = batch | |
| 272 | 289 | clock := c.opts.clock | |
| 273 | 290 | if clock == nil { | |
| 274 | 291 | c.mu.Unlock() | |
| 275 | 292 | return | |
| 276 | 293 | } | |
| 277 | - | timer := clock.NewTimer(maxSyncUpdateDuration) | |
| 278 | - | cancel := make(chan struct{}) | |
| 279 | - | done := make(chan struct{}) | |
| 280 | - | c.syncTimer, c.syncCancel, c.syncWorkerDone = timer, cancel, done | |
| 294 | + | batch.timer = clock.NewTimer(maxSyncUpdateDuration) | |
| 295 | + | batch.cancel = make(chan struct{}) | |
| 296 | + | batch.done = make(chan struct{}) | |
| 297 | + | timer, cancel, done := batch.timer, batch.cancel, batch.done | |
| 281 | 298 | c.mu.Unlock() | |
| 282 | 299 | go func() { | |
| 283 | 300 | defer close(done) | |
| @@ -287,38 +304,54 @@ func (c *renderCoordinator) noteSyncBegin(gen uint64, force ...func()) { | |||
| 287 | 304 | return | |
| 288 | 305 | } | |
| 289 | 306 | c.mu.Lock() | |
| 290 | - | valid := !c.torndown && c.syncGeneration == gen | |
| 291 | - | force := c.syncWatchdog | |
| 307 | + | current := c.syncBatches[p] | |
| 308 | + | valid := !c.torndown && current == batch && current.generation == gen | |
| 292 | 309 | if valid { | |
| 293 | - | c.syncGeneration = 0 | |
| 294 | - | c.syncWatchdog = nil | |
| 295 | - | c.cancelSyncTimerLocked() | |
| 310 | + | delete(c.syncBatches, p) | |
| 311 | + | cancelSyncBatchLocked(batch) | |
| 312 | + | if len(c.syncBatches) == 0 && c.pending { | |
| 313 | + | c.pendingUrgent = true | |
| 314 | + | } | |
| 296 | 315 | } | |
| 297 | 316 | c.mu.Unlock() | |
| 298 | 317 | if valid { | |
| 299 | - | if force != nil { | |
| 300 | - | force() | |
| 318 | + | if batch.force != nil { | |
| 319 | + | batch.force() | |
| 301 | 320 | } | |
| 321 | + | // Reevaluate the aggregate gate only after forcing this pane. | |
| 302 | 322 | c.fireCurrent(true) | |
| 303 | 323 | } | |
| 304 | 324 | }() | |
| 305 | 325 | } | |
| 306 | 326 | ||
| 307 | - | // noteSyncEnd records that the batch for gen completed, flushing pending | |
| 308 | - | // state in one wake. | |
| 309 | - | func (c *renderCoordinator) noteSyncEnd(gen uint64) { | |
| 327 | + | // noteSyncEnd records completion for exactly one pane batch. It only flushes | |
| 328 | + | // after the aggregate gate opens. | |
| 329 | + | func (c *renderCoordinator) noteSyncEnd(p *pane, gen uint64) { | |
| 310 | 330 | c.mu.Lock() | |
| 311 | - | if c.syncGeneration != gen { | |
| 331 | + | batch := c.syncBatches[p] | |
| 332 | + | if batch == nil || batch.generation != gen { | |
| 312 | 333 | c.mu.Unlock() | |
| 313 | 334 | return | |
| 314 | 335 | } | |
| 315 | - | c.syncGeneration = 0 | |
| 316 | - | c.syncWatchdog = nil | |
| 317 | - | c.cancelSyncTimerLocked() | |
| 336 | + | delete(c.syncBatches, p) | |
| 337 | + | cancelSyncBatchLocked(batch) | |
| 338 | + | if len(c.syncBatches) == 0 && c.pending { | |
| 339 | + | c.pendingUrgent = true | |
| 340 | + | } | |
| 318 | 341 | c.mu.Unlock() | |
| 319 | 342 | c.fireCurrent(false) | |
| 320 | 343 | } | |
| 321 | 344 | ||
| 345 | + | // noteSyncPaneRemoved releases a pane watchdog when pane lifecycle ends. | |
| 346 | + | func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) { | |
| 347 | + | c.mu.Lock() | |
| 348 | + | if batch := c.syncBatches[p]; batch != nil { | |
| 349 | + | delete(c.syncBatches, p) | |
| 350 | + | cancelSyncBatchLocked(batch) | |
| 351 | + | } | |
| 352 | + | c.mu.Unlock() | |
| 353 | + | } | |
| 354 | + | ||
| 322 | 355 | // subscribePreview installs fn as the preview observer for coalesced wakes. | |
| 323 | 356 | func (c *renderCoordinator) subscribePreview(fn func(renderWake)) { | |
| 324 | 357 | c.mu.Lock() | |
| @@ -372,7 +405,7 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) { | |||
| 372 | 405 | c.generation++ | |
| 373 | 406 | c.armed = false | |
| 374 | 407 | c.cancelNormalTimerLocked() | |
| 375 | - | c.cancelSyncTimerLocked() | |
| 408 | + | c.cancelSyncTimersLocked() | |
| 376 | 409 | } | |
| 377 | 410 | c.mu.Unlock() | |
| 378 | 411 | } | |
| @@ -388,7 +421,7 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient) { | |||
| 388 | 421 | c.generation++ | |
| 389 | 422 | c.armed = false | |
| 390 | 423 | c.cancelNormalTimerLocked() | |
| 391 | - | c.cancelSyncTimerLocked() | |
| 424 | + | c.cancelSyncTimersLocked() | |
| 392 | 425 | } | |
| 393 | 426 | c.mu.Unlock() | |
| 394 | 427 | } | |
| @@ -408,7 +441,7 @@ func (c *renderCoordinator) noteSessionTeardown() { | |||
| 408 | 441 | c.generation++ | |
| 409 | 442 | c.armed = false | |
| 410 | 443 | c.cancelNormalTimerLocked() | |
| 411 | - | c.cancelSyncTimerLocked() | |
| 444 | + | c.cancelSyncTimersLocked() | |
| 412 | 445 | c.mu.Unlock() | |
| 413 | 446 | } | |
| 414 | 447 | ||
| @@ -494,7 +527,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog bool) { | |||
| 494 | 527 | c.mu.Unlock() | |
| 495 | 528 | return | |
| 496 | 529 | } | |
| 497 | - | if !watchdog && c.syncGeneration != 0 { | |
| 530 | + | if len(c.syncBatches) != 0 { | |
| 498 | 531 | c.mu.Unlock() | |
| 499 | 532 | return | |
| 500 | 533 | } | |
| @@ -141,11 +141,9 @@ type renderCoordinator struct { | |||||
| 141 | normalTimer ports.Timer | 141 | normalTimer ports.Timer | ||
| 142 | normalCancel chan struct{} | 142 | normalCancel chan struct{} | ||
| 143 | normalWorkerDone chan struct{} | 143 | normalWorkerDone chan struct{} | ||
| 144 | - | syncGeneration uint64 | 144 | + | // syncBatches is keyed by the stable pane owner. Each pane owns an |
| 145 | - | syncWatchdog func() | 145 | + | // independent watchdog; composition remains gated until every batch ends. |
| 146 | - | syncTimer ports.Timer | 146 | + | syncBatches map[*pane]*syncBatch |
| 147 | - | syncCancel chan struct{} | |||
| 148 | - | syncWorkerDone chan struct{} | |||
| 149 | } | 147 | } | ||
| 150 | 148 | ||||
| 151 | // newRenderCoordinator constructs a coordinator bound to opts. It starts no | 149 | // newRenderCoordinator constructs a coordinator bound to opts. It starts no | ||
| @@ -167,14 +165,29 @@ func (c *renderCoordinator) cancelNormalTimerLocked() { | |||||
| 167 | } | 165 | } | ||
| 168 | } | 166 | } | ||
| 169 | 167 | ||||
| 170 | - | func (c *renderCoordinator) cancelSyncTimerLocked() { | 168 | + | type syncBatch struct { |
| 171 | - | if c.syncTimer != nil { | 169 | + | generation uint64 |
| 172 | - | c.syncTimer.Stop() | 170 | + | force func() |
| 173 | - | c.syncTimer = nil | 171 | + | timer ports.Timer |
| 172 | + | cancel chan struct{} | |||
| 173 | + | done chan struct{} | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func cancelSyncBatchLocked(batch *syncBatch) { | |||
| 177 | + | if batch.timer != nil { | |||
| 178 | + | batch.timer.Stop() | |||
| 179 | + | batch.timer = nil | |||
| 174 | } | 180 | } | ||
| 175 | - | if c.syncCancel != nil { | 181 | + | if batch.cancel != nil { |
| 176 | - | close(c.syncCancel) | 182 | + | close(batch.cancel) |
| 177 | - | c.syncCancel = nil | 183 | + | batch.cancel = nil |
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func (c *renderCoordinator) cancelSyncTimersLocked() { | |||
| 188 | + | for pane, batch := range c.syncBatches { | |||
| 189 | + | cancelSyncBatchLocked(batch) | |||
| 190 | + | delete(c.syncBatches, pane) | |||
| 178 | } | 191 | } | ||
| 179 | } | 192 | } | ||
| 180 | 193 | ||||
| @@ -254,30 +267,34 @@ func (c *renderCoordinator) notifyAck() { | |||||
| 254 | c.fire(gen, false) | 267 | c.fire(gen, false) | ||
| 255 | } | 268 | } | ||
| 256 | 269 | ||||
| 257 | - | // noteSyncBegin records that a synchronized-output batch opened for gen and | 270 | + | // noteSyncBegin records a synchronized-output batch for its stable pane and |
| 258 | - | // arms the completion watchdog. force is supplied by the pane owner so the | 271 | + | // arms that pane's watchdog. Overlapping pane batches are independent. |
| 259 | - | // coordinator can end a wedged VT batch before it composes the pending state. | 272 | + | func (c *renderCoordinator) noteSyncBegin(p *pane, gen uint64, force ...func()) { |
| 260 | - | func (c *renderCoordinator) noteSyncBegin(gen uint64, force ...func()) { | |||
| 261 | c.mu.Lock() | 273 | c.mu.Lock() | ||
| 262 | if c.torndown { | 274 | if c.torndown { | ||
| 263 | c.mu.Unlock() | 275 | c.mu.Unlock() | ||
| 264 | return | 276 | return | ||
| 265 | } | 277 | } | ||
| 266 | - | c.cancelSyncTimerLocked() | 278 | + | if c.syncBatches == nil { |
| 267 | - | c.syncGeneration = gen | 279 | + | c.syncBatches = make(map[*pane]*syncBatch) |
| 268 | - | c.syncWatchdog = nil | 280 | + | } |
| 281 | + | if old := c.syncBatches[p]; old != nil { | |||
| 282 | + | cancelSyncBatchLocked(old) | |||
| 283 | + | } | |||
| 284 | + | batch := &syncBatch{generation: gen} | |||
| 269 | if len(force) != 0 { | 285 | if len(force) != 0 { | ||
| 270 | - | c.syncWatchdog = force[0] | 286 | + | batch.force = force[0] |
| 271 | } | 287 | } | ||
| 288 | + | c.syncBatches[p] = batch | |||
| 272 | clock := c.opts.clock | 289 | clock := c.opts.clock | ||
| 273 | if clock == nil { | 290 | if clock == nil { | ||
| 274 | c.mu.Unlock() | 291 | c.mu.Unlock() | ||
| 275 | return | 292 | return | ||
| 276 | } | 293 | } | ||
| 277 | - | timer := clock.NewTimer(maxSyncUpdateDuration) | 294 | + | batch.timer = clock.NewTimer(maxSyncUpdateDuration) |
| 278 | - | cancel := make(chan struct{}) | 295 | + | batch.cancel = make(chan struct{}) |
| 279 | - | done := make(chan struct{}) | 296 | + | batch.done = make(chan struct{}) |
| 280 | - | c.syncTimer, c.syncCancel, c.syncWorkerDone = timer, cancel, done | 297 | + | timer, cancel, done := batch.timer, batch.cancel, batch.done |
| 281 | c.mu.Unlock() | 298 | c.mu.Unlock() | ||
| 282 | go func() { | 299 | go func() { | ||
| 283 | defer close(done) | 300 | defer close(done) | ||
| @@ -287,38 +304,54 @@ func (c *renderCoordinator) noteSyncBegin(gen uint64, force ...func()) { | |||||
| 287 | return | 304 | return | ||
| 288 | } | 305 | } | ||
| 289 | c.mu.Lock() | 306 | c.mu.Lock() | ||
| 290 | - | valid := !c.torndown && c.syncGeneration == gen | 307 | + | current := c.syncBatches[p] |
| 291 | - | force := c.syncWatchdog | 308 | + | valid := !c.torndown && current == batch && current.generation == gen |
| 292 | if valid { | 309 | if valid { | ||
| 293 | - | c.syncGeneration = 0 | 310 | + | delete(c.syncBatches, p) |
| 294 | - | c.syncWatchdog = nil | 311 | + | cancelSyncBatchLocked(batch) |
| 295 | - | c.cancelSyncTimerLocked() | 312 | + | if len(c.syncBatches) == 0 && c.pending { |
| 313 | + | c.pendingUrgent = true | |||
| 314 | + | } | |||
| 296 | } | 315 | } | ||
| 297 | c.mu.Unlock() | 316 | c.mu.Unlock() | ||
| 298 | if valid { | 317 | if valid { | ||
| 299 | - | if force != nil { | 318 | + | if batch.force != nil { |
| 300 | - | force() | 319 | + | batch.force() |
| 301 | } | 320 | } | ||
| 321 | + | // Reevaluate the aggregate gate only after forcing this pane. | |||
| 302 | c.fireCurrent(true) | 322 | c.fireCurrent(true) | ||
| 303 | } | 323 | } | ||
| 304 | }() | 324 | }() | ||
| 305 | } | 325 | } | ||
| 306 | 326 | ||||
| 307 | - | // noteSyncEnd records that the batch for gen completed, flushing pending | 327 | + | // noteSyncEnd records completion for exactly one pane batch. It only flushes |
| 308 | - | // state in one wake. | 328 | + | // after the aggregate gate opens. |
| 309 | - | func (c *renderCoordinator) noteSyncEnd(gen uint64) { | 329 | + | func (c *renderCoordinator) noteSyncEnd(p *pane, gen uint64) { |
| 310 | c.mu.Lock() | 330 | c.mu.Lock() | ||
| 311 | - | if c.syncGeneration != gen { | 331 | + | batch := c.syncBatches[p] |
| 332 | + | if batch == nil || batch.generation != gen { | |||
| 312 | c.mu.Unlock() | 333 | c.mu.Unlock() | ||
| 313 | return | 334 | return | ||
| 314 | } | 335 | } | ||
| 315 | - | c.syncGeneration = 0 | 336 | + | delete(c.syncBatches, p) |
| 316 | - | c.syncWatchdog = nil | 337 | + | cancelSyncBatchLocked(batch) |
| 317 | - | c.cancelSyncTimerLocked() | 338 | + | if len(c.syncBatches) == 0 && c.pending { |
| 339 | + | c.pendingUrgent = true | |||
| 340 | + | } | |||
| 318 | c.mu.Unlock() | 341 | c.mu.Unlock() | ||
| 319 | c.fireCurrent(false) | 342 | c.fireCurrent(false) | ||
| 320 | } | 343 | } | ||
| 321 | 344 | ||||
| 345 | + | // noteSyncPaneRemoved releases a pane watchdog when pane lifecycle ends. | |||
| 346 | + | func (c *renderCoordinator) noteSyncPaneRemoved(p *pane) { | |||
| 347 | + | c.mu.Lock() | |||
| 348 | + | if batch := c.syncBatches[p]; batch != nil { | |||
| 349 | + | delete(c.syncBatches, p) | |||
| 350 | + | cancelSyncBatchLocked(batch) | |||
| 351 | + | } | |||
| 352 | + | c.mu.Unlock() | |||
| 353 | + | } | |||
| 354 | + | ||||
| 322 | // subscribePreview installs fn as the preview observer for coalesced wakes. | 355 | // subscribePreview installs fn as the preview observer for coalesced wakes. | ||
| 323 | func (c *renderCoordinator) subscribePreview(fn func(renderWake)) { | 356 | func (c *renderCoordinator) subscribePreview(fn func(renderWake)) { | ||
| 324 | c.mu.Lock() | 357 | c.mu.Lock() | ||
| @@ -372,7 +405,7 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) { | |||||
| 372 | c.generation++ | 405 | c.generation++ | ||
| 373 | c.armed = false | 406 | c.armed = false | ||
| 374 | c.cancelNormalTimerLocked() | 407 | c.cancelNormalTimerLocked() | ||
| 375 | - | c.cancelSyncTimerLocked() | 408 | + | c.cancelSyncTimersLocked() |
| 376 | } | 409 | } | ||
| 377 | c.mu.Unlock() | 410 | c.mu.Unlock() | ||
| 378 | } | 411 | } | ||
| @@ -388,7 +421,7 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient) { | |||||
| 388 | c.generation++ | 421 | c.generation++ | ||
| 389 | c.armed = false | 422 | c.armed = false | ||
| 390 | c.cancelNormalTimerLocked() | 423 | c.cancelNormalTimerLocked() | ||
| 391 | - | c.cancelSyncTimerLocked() | 424 | + | c.cancelSyncTimersLocked() |
| 392 | } | 425 | } | ||
| 393 | c.mu.Unlock() | 426 | c.mu.Unlock() | ||
| 394 | } | 427 | } | ||
| @@ -408,7 +441,7 @@ func (c *renderCoordinator) noteSessionTeardown() { | |||||
| 408 | c.generation++ | 441 | c.generation++ | ||
| 409 | c.armed = false | 442 | c.armed = false | ||
| 410 | c.cancelNormalTimerLocked() | 443 | c.cancelNormalTimerLocked() | ||
| 411 | - | c.cancelSyncTimerLocked() | 444 | + | c.cancelSyncTimersLocked() |
| 412 | c.mu.Unlock() | 445 | c.mu.Unlock() | ||
| 413 | } | 446 | } | ||
| 414 | 447 | ||||
| @@ -494,7 +527,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog bool) { | |||||
| 494 | c.mu.Unlock() | 527 | c.mu.Unlock() | ||
| 495 | return | 528 | return | ||
| 496 | } | 529 | } | ||
| 497 | - | if !watchdog && c.syncGeneration != 0 { | 530 | + | if len(c.syncBatches) != 0 { |
| 498 | c.mu.Unlock() | 531 | c.mu.Unlock() | ||
| 499 | return | 532 | return | ||
| 500 | } | 533 | } | ||
M internal/usecase/daemon/render_coordinator_test.go +43 -16
| @@ -342,17 +342,17 @@ func TestRenderCoordinatorConcurrentSynchronizedPaneBatches(t *testing.T) { | |||
| 342 | 342 | t.Run("end orders, watchdogs, and stale callbacks are isolated per pane", func(t *testing.T) { | |
| 343 | 343 | h := newCoordinatorHarness(t) | |
| 344 | 344 | firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1) | |
| 345 | + | firstPane, secondPane := &pane{}, &pane{} | |
| 345 | 346 | ||
| 346 | - | // Distinct generations model distinct panes. The coordinator must retain | |
| 347 | - | // both batches rather than letting the later begin replace the first. | |
| 348 | - | h.rc.noteSyncBegin(11, func() { firstForced <- struct{}{} }) | |
| 347 | + | // Distinct stable pane owners must remain independent. | |
| 348 | + | h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} }) | |
| 349 | 349 | firstWatchdog := h.armedTimers(t)[0] | |
| 350 | - | h.rc.noteSyncBegin(22, func() { secondForced <- struct{}{} }) | |
| 350 | + | h.rc.noteSyncBegin(secondPane, 22, func() { secondForced <- struct{}{} }) | |
| 351 | 351 | secondWatchdog := h.armedTimers(t)[0] | |
| 352 | 352 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |
| 353 | 353 | ||
| 354 | 354 | // Completing either pane leaves the aggregate gate closed. | |
| 355 | - | h.rc.noteSyncEnd(11) | |
| 355 | + | h.rc.noteSyncEnd(firstPane, 11) | |
| 356 | 356 | requireNoWake(t, h.wakes) | |
| 357 | 357 | // A stale callback for the completed pane must not force it or wake. | |
| 358 | 358 | firstWatchdog.ch <- time.Time{} | |
| @@ -369,23 +369,50 @@ func TestRenderCoordinatorConcurrentSynchronizedPaneBatches(t *testing.T) { | |||
| 369 | 369 | <-secondForced | |
| 370 | 370 | w := awaitWake(t, h.wakes) | |
| 371 | 371 | require.True(t, w.watchdog) | |
| 372 | + | require.True(t, w.urgent, "aggregate completion must wake urgently") | |
| 372 | 373 | requireNoWake(t, h.wakes) | |
| 373 | 374 | }) | |
| 374 | 375 | ||
| 375 | 376 | t.Run("later pane can end first without releasing an earlier pane", func(t *testing.T) { | |
| 376 | 377 | h := newCoordinatorHarness(t) | |
| 377 | 378 | firstForced := make(chan struct{}, 1) | |
| 378 | - | h.rc.noteSyncBegin(11, func() { firstForced <- struct{}{} }) | |
| 379 | + | firstPane, secondPane := &pane{}, &pane{} | |
| 380 | + | h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} }) | |
| 379 | 381 | firstWatchdog := h.armedTimers(t)[0] | |
| 380 | - | h.rc.noteSyncBegin(22) | |
| 382 | + | h.rc.noteSyncBegin(secondPane, 22) | |
| 381 | 383 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |
| 382 | 384 | ||
| 383 | - | h.rc.noteSyncEnd(22) | |
| 385 | + | h.rc.noteSyncEnd(secondPane, 22) | |
| 384 | 386 | requireNoWake(t, h.wakes) | |
| 385 | 387 | firstWatchdog.ch <- time.Time{} | |
| 386 | 388 | <-firstForced | |
| 387 | - | require.True(t, awaitWake(t, h.wakes).watchdog) | |
| 389 | + | w := awaitWake(t, h.wakes) | |
| 390 | + | require.True(t, w.watchdog) | |
| 391 | + | require.True(t, w.urgent, "final pane completion must wake urgently") | |
| 392 | + | requireNoWake(t, h.wakes) | |
| 393 | + | }) | |
| 394 | + | ||
| 395 | + | t.Run("pane removal cancels only that pane watchdog", func(t *testing.T) { | |
| 396 | + | h := newCoordinatorHarness(t) | |
| 397 | + | firstPane, secondPane := &pane{}, &pane{} | |
| 398 | + | firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1) | |
| 399 | + | h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} }) | |
| 400 | + | firstWatchdog := h.armedTimers(t)[0] | |
| 401 | + | h.rc.noteSyncBegin(secondPane, 22, func() { secondForced <- struct{}{} }) | |
| 402 | + | secondWatchdog := h.armedTimers(t)[0] | |
| 403 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |
| 404 | + | ||
| 405 | + | h.rc.noteSyncPaneRemoved(firstPane) | |
| 406 | + | firstWatchdog.ch <- time.Time{} | |
| 407 | + | select { | |
| 408 | + | case <-firstForced: | |
| 409 | + | t.Fatal("removed pane watchdog forced output") | |
| 410 | + | default: | |
| 411 | + | } | |
| 388 | 412 | requireNoWake(t, h.wakes) | |
| 413 | + | secondWatchdog.ch <- time.Time{} | |
| 414 | + | <-secondForced | |
| 415 | + | require.True(t, awaitWake(t, h.wakes).watchdog) | |
| 389 | 416 | }) | |
| 390 | 417 | } | |
| 391 | 418 | ||
| @@ -393,7 +420,7 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) { | |||
| 393 | 420 | t.Run("completion flushes pending state in one wake", func(t *testing.T) { | |
| 394 | 421 | h := newCoordinatorHarness(t) | |
| 395 | 422 | h.syncActive.Store(true) | |
| 396 | - | h.rc.noteSyncBegin(1) | |
| 423 | + | h.rc.noteSyncBegin(nil, 1) | |
| 397 | 424 | watchdogs := h.armedTimers(t) | |
| 398 | 425 | require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog") | |
| 399 | 426 | require.Equal(t, maxSyncUpdateDuration, watchdogs[len(watchdogs)-1].duration) | |
| @@ -405,7 +432,7 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) { | |||
| 405 | 432 | requireNoWake(t, h.wakes) | |
| 406 | 433 | ||
| 407 | 434 | h.syncActive.Store(false) | |
| 408 | - | h.rc.noteSyncEnd(1) | |
| 435 | + | h.rc.noteSyncEnd(nil, 1) | |
| 409 | 436 | w := awaitWake(t, h.wakes) | |
| 410 | 437 | require.True(t, w.reset) | |
| 411 | 438 | requireNoWake(t, h.wakes) | |
| @@ -415,7 +442,7 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) { | |||
| 415 | 442 | h := newCoordinatorHarness(t) | |
| 416 | 443 | h.syncActive.Store(true) | |
| 417 | 444 | forced := make(chan struct{}, 1) | |
| 418 | - | h.rc.noteSyncBegin(7, func() { forced <- struct{}{} }) | |
| 445 | + | h.rc.noteSyncBegin(nil, 7, func() { forced <- struct{}{} }) | |
| 419 | 446 | watchdogs := h.armedTimers(t) | |
| 420 | 447 | require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog") | |
| 421 | 448 | h.rc.invalidate(renderInvalidation{class: invalidateOutput}) | |
| @@ -430,11 +457,11 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) { | |||
| 430 | 457 | t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) { | |
| 431 | 458 | h := newCoordinatorHarness(t) | |
| 432 | 459 | h.syncActive.Store(true) | |
| 433 | - | h.rc.noteSyncBegin(1) | |
| 460 | + | h.rc.noteSyncBegin(nil, 1) | |
| 434 | 461 | watchdogs := h.armedTimers(t) | |
| 435 | 462 | require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog") | |
| 436 | 463 | h.syncActive.Store(false) | |
| 437 | - | h.rc.noteSyncEnd(1) | |
| 464 | + | h.rc.noteSyncEnd(nil, 1) | |
| 438 | 465 | ||
| 439 | 466 | watchdogs[0].ch <- time.Time{} | |
| 440 | 467 | requireNoWake(t, h.wakes) | |
| @@ -968,10 +995,10 @@ func TestRenderCoordinatorStopsInertSyncTimerWorkers(t *testing.T) { | |||
| 968 | 995 | rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk.clock}) | |
| 969 | 996 | ac := &attachedClient{} | |
| 970 | 997 | rc.attach(ac) | |
| 971 | - | rc.noteSyncBegin(1) | |
| 998 | + | rc.noteSyncBegin(nil, 1) | |
| 972 | 999 | syncTimer := <-clk.timers | |
| 973 | 1000 | rc.mu.Lock() | |
| 974 | - | syncDone := rc.syncWorkerDone | |
| 1001 | + | syncDone := rc.syncBatches[nil].done | |
| 975 | 1002 | rc.mu.Unlock() | |
| 976 | 1003 | tc.end(rc, ac) | |
| 977 | 1004 | syncTimer.mock.AssertNumberOfCalls(t, "Stop", 1) | |
| @@ -342,17 +342,17 @@ func TestRenderCoordinatorConcurrentSynchronizedPaneBatches(t *testing.T) { | |||||
| 342 | t.Run("end orders, watchdogs, and stale callbacks are isolated per pane", func(t *testing.T) { | 342 | t.Run("end orders, watchdogs, and stale callbacks are isolated per pane", func(t *testing.T) { | ||
| 343 | h := newCoordinatorHarness(t) | 343 | h := newCoordinatorHarness(t) | ||
| 344 | firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1) | 344 | firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1) | ||
| 345 | + | firstPane, secondPane := &pane{}, &pane{} | |||
| 345 | 346 | ||||
| 346 | - | // Distinct generations model distinct panes. The coordinator must retain | 347 | + | // Distinct stable pane owners must remain independent. |
| 347 | - | // both batches rather than letting the later begin replace the first. | 348 | + | h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} }) |
| 348 | - | h.rc.noteSyncBegin(11, func() { firstForced <- struct{}{} }) | |||
| 349 | firstWatchdog := h.armedTimers(t)[0] | 349 | firstWatchdog := h.armedTimers(t)[0] | ||
| 350 | - | h.rc.noteSyncBegin(22, func() { secondForced <- struct{}{} }) | 350 | + | h.rc.noteSyncBegin(secondPane, 22, func() { secondForced <- struct{}{} }) |
| 351 | secondWatchdog := h.armedTimers(t)[0] | 351 | secondWatchdog := h.armedTimers(t)[0] | ||
| 352 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | 352 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | ||
| 353 | 353 | ||||
| 354 | // Completing either pane leaves the aggregate gate closed. | 354 | // Completing either pane leaves the aggregate gate closed. | ||
| 355 | - | h.rc.noteSyncEnd(11) | 355 | + | h.rc.noteSyncEnd(firstPane, 11) |
| 356 | requireNoWake(t, h.wakes) | 356 | requireNoWake(t, h.wakes) | ||
| 357 | // A stale callback for the completed pane must not force it or wake. | 357 | // A stale callback for the completed pane must not force it or wake. | ||
| 358 | firstWatchdog.ch <- time.Time{} | 358 | firstWatchdog.ch <- time.Time{} | ||
| @@ -369,23 +369,50 @@ func TestRenderCoordinatorConcurrentSynchronizedPaneBatches(t *testing.T) { | |||||
| 369 | <-secondForced | 369 | <-secondForced | ||
| 370 | w := awaitWake(t, h.wakes) | 370 | w := awaitWake(t, h.wakes) | ||
| 371 | require.True(t, w.watchdog) | 371 | require.True(t, w.watchdog) | ||
| 372 | + | require.True(t, w.urgent, "aggregate completion must wake urgently") | |||
| 372 | requireNoWake(t, h.wakes) | 373 | requireNoWake(t, h.wakes) | ||
| 373 | }) | 374 | }) | ||
| 374 | 375 | ||||
| 375 | t.Run("later pane can end first without releasing an earlier pane", func(t *testing.T) { | 376 | t.Run("later pane can end first without releasing an earlier pane", func(t *testing.T) { | ||
| 376 | h := newCoordinatorHarness(t) | 377 | h := newCoordinatorHarness(t) | ||
| 377 | firstForced := make(chan struct{}, 1) | 378 | firstForced := make(chan struct{}, 1) | ||
| 378 | - | h.rc.noteSyncBegin(11, func() { firstForced <- struct{}{} }) | 379 | + | firstPane, secondPane := &pane{}, &pane{} |
| 380 | + | h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} }) | |||
| 379 | firstWatchdog := h.armedTimers(t)[0] | 381 | firstWatchdog := h.armedTimers(t)[0] | ||
| 380 | - | h.rc.noteSyncBegin(22) | 382 | + | h.rc.noteSyncBegin(secondPane, 22) |
| 381 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | 383 | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | ||
| 382 | 384 | ||||
| 383 | - | h.rc.noteSyncEnd(22) | 385 | + | h.rc.noteSyncEnd(secondPane, 22) |
| 384 | requireNoWake(t, h.wakes) | 386 | requireNoWake(t, h.wakes) | ||
| 385 | firstWatchdog.ch <- time.Time{} | 387 | firstWatchdog.ch <- time.Time{} | ||
| 386 | <-firstForced | 388 | <-firstForced | ||
| 387 | - | require.True(t, awaitWake(t, h.wakes).watchdog) | 389 | + | w := awaitWake(t, h.wakes) |
| 390 | + | require.True(t, w.watchdog) | |||
| 391 | + | require.True(t, w.urgent, "final pane completion must wake urgently") | |||
| 392 | + | requireNoWake(t, h.wakes) | |||
| 393 | + | }) | |||
| 394 | + | ||||
| 395 | + | t.Run("pane removal cancels only that pane watchdog", func(t *testing.T) { | |||
| 396 | + | h := newCoordinatorHarness(t) | |||
| 397 | + | firstPane, secondPane := &pane{}, &pane{} | |||
| 398 | + | firstForced, secondForced := make(chan struct{}, 1), make(chan struct{}, 1) | |||
| 399 | + | h.rc.noteSyncBegin(firstPane, 11, func() { firstForced <- struct{}{} }) | |||
| 400 | + | firstWatchdog := h.armedTimers(t)[0] | |||
| 401 | + | h.rc.noteSyncBegin(secondPane, 22, func() { secondForced <- struct{}{} }) | |||
| 402 | + | secondWatchdog := h.armedTimers(t)[0] | |||
| 403 | + | h.rc.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"}) | |||
| 404 | + | ||||
| 405 | + | h.rc.noteSyncPaneRemoved(firstPane) | |||
| 406 | + | firstWatchdog.ch <- time.Time{} | |||
| 407 | + | select { | |||
| 408 | + | case <-firstForced: | |||
| 409 | + | t.Fatal("removed pane watchdog forced output") | |||
| 410 | + | default: | |||
| 411 | + | } | |||
| 388 | requireNoWake(t, h.wakes) | 412 | requireNoWake(t, h.wakes) | ||
| 413 | + | secondWatchdog.ch <- time.Time{} | |||
| 414 | + | <-secondForced | |||
| 415 | + | require.True(t, awaitWake(t, h.wakes).watchdog) | |||
| 389 | }) | 416 | }) | ||
| 390 | } | 417 | } | ||
| 391 | 418 | ||||
| @@ -393,7 +420,7 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) { | |||||
| 393 | t.Run("completion flushes pending state in one wake", func(t *testing.T) { | 420 | t.Run("completion flushes pending state in one wake", func(t *testing.T) { | ||
| 394 | h := newCoordinatorHarness(t) | 421 | h := newCoordinatorHarness(t) | ||
| 395 | h.syncActive.Store(true) | 422 | h.syncActive.Store(true) | ||
| 396 | - | h.rc.noteSyncBegin(1) | 423 | + | h.rc.noteSyncBegin(nil, 1) |
| 397 | watchdogs := h.armedTimers(t) | 424 | watchdogs := h.armedTimers(t) | ||
| 398 | require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog") | 425 | require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog") | ||
| 399 | require.Equal(t, maxSyncUpdateDuration, watchdogs[len(watchdogs)-1].duration) | 426 | require.Equal(t, maxSyncUpdateDuration, watchdogs[len(watchdogs)-1].duration) | ||
| @@ -405,7 +432,7 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) { | |||||
| 405 | requireNoWake(t, h.wakes) | 432 | requireNoWake(t, h.wakes) | ||
| 406 | 433 | ||||
| 407 | h.syncActive.Store(false) | 434 | h.syncActive.Store(false) | ||
| 408 | - | h.rc.noteSyncEnd(1) | 435 | + | h.rc.noteSyncEnd(nil, 1) |
| 409 | w := awaitWake(t, h.wakes) | 436 | w := awaitWake(t, h.wakes) | ||
| 410 | require.True(t, w.reset) | 437 | require.True(t, w.reset) | ||
| 411 | requireNoWake(t, h.wakes) | 438 | requireNoWake(t, h.wakes) | ||
| @@ -415,7 +442,7 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) { | |||||
| 415 | h := newCoordinatorHarness(t) | 442 | h := newCoordinatorHarness(t) | ||
| 416 | h.syncActive.Store(true) | 443 | h.syncActive.Store(true) | ||
| 417 | forced := make(chan struct{}, 1) | 444 | forced := make(chan struct{}, 1) | ||
| 418 | - | h.rc.noteSyncBegin(7, func() { forced <- struct{}{} }) | 445 | + | h.rc.noteSyncBegin(nil, 7, func() { forced <- struct{}{} }) |
| 419 | watchdogs := h.armedTimers(t) | 446 | watchdogs := h.armedTimers(t) | ||
| 420 | require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog") | 447 | require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog") | ||
| 421 | h.rc.invalidate(renderInvalidation{class: invalidateOutput}) | 448 | h.rc.invalidate(renderInvalidation{class: invalidateOutput}) | ||
| @@ -430,11 +457,11 @@ func TestRenderCoordinatorSynchronizedOutput(t *testing.T) { | |||||
| 430 | t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) { | 457 | t.Run("stale watchdog generation cannot wake a completed batch", func(t *testing.T) { | ||
| 431 | h := newCoordinatorHarness(t) | 458 | h := newCoordinatorHarness(t) | ||
| 432 | h.syncActive.Store(true) | 459 | h.syncActive.Store(true) | ||
| 433 | - | h.rc.noteSyncBegin(1) | 460 | + | h.rc.noteSyncBegin(nil, 1) |
| 434 | watchdogs := h.armedTimers(t) | 461 | watchdogs := h.armedTimers(t) | ||
| 435 | require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog") | 462 | require.NotEmpty(t, watchdogs, "sync begin must arm the completion watchdog") | ||
| 436 | h.syncActive.Store(false) | 463 | h.syncActive.Store(false) | ||
| 437 | - | h.rc.noteSyncEnd(1) | 464 | + | h.rc.noteSyncEnd(nil, 1) |
| 438 | 465 | ||||
| 439 | watchdogs[0].ch <- time.Time{} | 466 | watchdogs[0].ch <- time.Time{} | ||
| 440 | requireNoWake(t, h.wakes) | 467 | requireNoWake(t, h.wakes) | ||
| @@ -968,10 +995,10 @@ func TestRenderCoordinatorStopsInertSyncTimerWorkers(t *testing.T) { | |||||
| 968 | rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk.clock}) | 995 | rc := newRenderCoordinator(renderCoordinatorOptions{clock: clk.clock}) | ||
| 969 | ac := &attachedClient{} | 996 | ac := &attachedClient{} | ||
| 970 | rc.attach(ac) | 997 | rc.attach(ac) | ||
| 971 | - | rc.noteSyncBegin(1) | 998 | + | rc.noteSyncBegin(nil, 1) |
| 972 | syncTimer := <-clk.timers | 999 | syncTimer := <-clk.timers | ||
| 973 | rc.mu.Lock() | 1000 | rc.mu.Lock() | ||
| 974 | - | syncDone := rc.syncWorkerDone | 1001 | + | syncDone := rc.syncBatches[nil].done |
| 975 | rc.mu.Unlock() | 1002 | rc.mu.Unlock() | ||
| 976 | tc.end(rc, ac) | 1003 | tc.end(rc, ac) | ||
| 977 | syncTimer.mock.AssertNumberOfCalls(t, "Stop", 1) | 1004 | syncTimer.mock.AssertNumberOfCalls(t, "Stop", 1) | ||
M internal/usecase/daemon/session.go +8 -0
| @@ -620,6 +620,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 620 | 620 | tb.cancel() | |
| 621 | 621 | } | |
| 622 | 622 | d.teardownFloating(tb, ac) | |
| 623 | + | if rc := sess.renderCoordinator(); rc != nil { | |
| 624 | + | tb.mu.Lock() | |
| 625 | + | panes := tb.panesSnapshot() | |
| 626 | + | tb.mu.Unlock() | |
| 627 | + | for _, p := range panes { | |
| 628 | + | rc.noteSyncPaneRemoved(p) | |
| 629 | + | } | |
| 630 | + | } | |
| 623 | 631 | tb.closeAllPanes() | |
| 624 | 632 | if wasActive { | |
| 625 | 633 | d.activateTab(sess, destination) | |
| @@ -620,6 +620,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 620 | tb.cancel() | 620 | tb.cancel() | ||
| 621 | } | 621 | } | ||
| 622 | d.teardownFloating(tb, ac) | 622 | d.teardownFloating(tb, ac) | ||
| 623 | + | if rc := sess.renderCoordinator(); rc != nil { | |||
| 624 | + | tb.mu.Lock() | |||
| 625 | + | panes := tb.panesSnapshot() | |||
| 626 | + | tb.mu.Unlock() | |||
| 627 | + | for _, p := range panes { | |||
| 628 | + | rc.noteSyncPaneRemoved(p) | |||
| 629 | + | } | |||
| 630 | + | } | |||
| 623 | tb.closeAllPanes() | 631 | tb.closeAllPanes() | ||
| 624 | if wasActive { | 632 | if wasActive { | ||
| 625 | d.activateTab(sess, destination) | 633 | d.activateTab(sess, destination) | ||
M internal/usecase/daemon/snapshot_test.go +1 -1
| @@ -219,7 +219,7 @@ func TestPTYReaderSameReadSynchronizedOutputUsesUrgentCoordinatorDeadline(t *tes | |||
| 219 | 219 | sctx, cancel := context.WithCancel(context.Background()) | |
| 220 | 220 | defer cancel() | |
| 221 | 221 | tb := newTestTabWithContext(p, sctx, cancel) | |
| 222 | - | sess := &session{id: "sync-urgent", name: "sync-urgent", tabs: []*tab{tb}, ctx: sctx, cancel: cancel} | |
| 222 | + | sess := &session{id: "sync-urgent", name: "sync-urgent", tabs: []*tab{tb}, client: &attachedClient{}, ctx: sctx, cancel: cancel} | |
| 223 | 223 | invs := make(chan renderInvalidation, 1) | |
| 224 | 224 | clock := newCoordinatorMockClock(t, 2) | |
| 225 | 225 | sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{ | |
| @@ -219,7 +219,7 @@ func TestPTYReaderSameReadSynchronizedOutputUsesUrgentCoordinatorDeadline(t *tes | |||||
| 219 | sctx, cancel := context.WithCancel(context.Background()) | 219 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 220 | defer cancel() | 220 | defer cancel() | ||
| 221 | tb := newTestTabWithContext(p, sctx, cancel) | 221 | tb := newTestTabWithContext(p, sctx, cancel) | ||
| 222 | - | sess := &session{id: "sync-urgent", name: "sync-urgent", tabs: []*tab{tb}, ctx: sctx, cancel: cancel} | 222 | + | sess := &session{id: "sync-urgent", name: "sync-urgent", tabs: []*tab{tb}, client: &attachedClient{}, ctx: sctx, cancel: cancel} |
| 223 | invs := make(chan renderInvalidation, 1) | 223 | invs := make(chan renderInvalidation, 1) | ||
| 224 | clock := newCoordinatorMockClock(t, 2) | 224 | clock := newCoordinatorMockClock(t, 2) | ||
| 225 | sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{ | 225 | sess.installRenderCoordinator(newRenderCoordinator(renderCoordinatorOptions{ | ||