fix(daemon): preserve coordinator ownership across sessions
- commit
- 4e6142e341001f64f5970285f799d735750ee6ac
- author
- brice · · 2026-07-11 16:33 UTC
- parent
- a4184b2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +144 -32
M internal/usecase/daemon/client.go +25 -16
| @@ -355,43 +355,52 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||
| 355 | 355 | sess.client = ac | |
| 356 | 356 | name := sess.name | |
| 357 | 357 | sess.mu.Unlock() | |
| 358 | - | // A session has exactly one coordinator; replacement changes only its | |
| 359 | - | // attachment identity and the private transitional compositor target. | |
| 358 | + | d.attachCoordinator(sess, old, ac) | |
| 359 | + | d.touchMRU(sess) | |
| 360 | + | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | |
| 361 | + | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true) | |
| 362 | + | return ac, old | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | // attachCoordinator is the sole direct attachment handoff. It creates at | |
| 366 | + | // most one coordinator for sess and changes its identity before any caller | |
| 367 | + | // can publish resize or render state for the new client. | |
| 368 | + | func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient) *renderCoordinator { | |
| 369 | + | sess.mu.Lock() | |
| 360 | 370 | rc := sess.renderCoordinator() | |
| 361 | 371 | if rc == nil { | |
| 362 | 372 | rc = newRenderCoordinator(renderCoordinatorOptions{ | |
| 363 | 373 | clock: d.clock, | |
| 364 | 374 | wake: func(w renderWake) { | |
| 365 | 375 | sess.mu.Lock() | |
| 366 | - | current := sess.client | |
| 376 | + | attached := sess.client | |
| 367 | 377 | sess.mu.Unlock() | |
| 368 | - | if current != nil { | |
| 369 | - | d.paint(sess, current, w.reset) | |
| 378 | + | if attached != nil { | |
| 379 | + | d.paint(sess, attached, w.reset) | |
| 370 | 380 | } | |
| 371 | 381 | }, | |
| 372 | 382 | ackReady: func() bool { | |
| 373 | 383 | sess.mu.Lock() | |
| 374 | - | current := sess.client | |
| 384 | + | attached := sess.client | |
| 375 | 385 | sess.mu.Unlock() | |
| 376 | - | if current == nil { | |
| 386 | + | if attached == nil { | |
| 377 | 387 | return false | |
| 378 | 388 | } | |
| 379 | - | current.sendMu.Lock() | |
| 380 | - | ready := !current.output.atCapacity() | |
| 381 | - | current.sendMu.Unlock() | |
| 389 | + | attached.sendMu.Lock() | |
| 390 | + | ready := !attached.output.atCapacity() | |
| 391 | + | attached.sendMu.Unlock() | |
| 382 | 392 | return ready | |
| 383 | 393 | }, | |
| 384 | 394 | }) | |
| 385 | 395 | sess.installRenderCoordinator(rc) | |
| 386 | 396 | } | |
| 397 | + | sess.mu.Unlock() | |
| 387 | 398 | if old != nil { | |
| 388 | - | rc.noteReplace(old, ac) | |
| 399 | + | rc.noteReplace(old, current) | |
| 400 | + | } else if current != nil { | |
| 401 | + | rc.attach(current) | |
| 389 | 402 | } | |
| 390 | - | rc.attach(ac) | |
| 391 | - | d.touchMRU(sess) | |
| 392 | - | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | |
| 393 | - | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true) | |
| 394 | - | return ac, old | |
| 403 | + | return rc | |
| 395 | 404 | } | |
| 396 | 405 | ||
| 397 | 406 | // resetScreenDefaultColors clears the known default foreground/background | |
| @@ -355,43 +355,52 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||||
| 355 | sess.client = ac | 355 | sess.client = ac | ||
| 356 | name := sess.name | 356 | name := sess.name | ||
| 357 | sess.mu.Unlock() | 357 | sess.mu.Unlock() | ||
| 358 | - | // A session has exactly one coordinator; replacement changes only its | 358 | + | d.attachCoordinator(sess, old, ac) |
| 359 | - | // attachment identity and the private transitional compositor target. | 359 | + | d.touchMRU(sess) |
| 360 | + | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | |||
| 361 | + | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true) | |||
| 362 | + | return ac, old | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | // attachCoordinator is the sole direct attachment handoff. It creates at | |||
| 366 | + | // most one coordinator for sess and changes its identity before any caller | |||
| 367 | + | // can publish resize or render state for the new client. | |||
| 368 | + | func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient) *renderCoordinator { | |||
| 369 | + | sess.mu.Lock() | |||
| 360 | rc := sess.renderCoordinator() | 370 | rc := sess.renderCoordinator() | ||
| 361 | if rc == nil { | 371 | if rc == nil { | ||
| 362 | rc = newRenderCoordinator(renderCoordinatorOptions{ | 372 | rc = newRenderCoordinator(renderCoordinatorOptions{ | ||
| 363 | clock: d.clock, | 373 | clock: d.clock, | ||
| 364 | wake: func(w renderWake) { | 374 | wake: func(w renderWake) { | ||
| 365 | sess.mu.Lock() | 375 | sess.mu.Lock() | ||
| 366 | - | current := sess.client | 376 | + | attached := sess.client |
| 367 | sess.mu.Unlock() | 377 | sess.mu.Unlock() | ||
| 368 | - | if current != nil { | 378 | + | if attached != nil { |
| 369 | - | d.paint(sess, current, w.reset) | 379 | + | d.paint(sess, attached, w.reset) |
| 370 | } | 380 | } | ||
| 371 | }, | 381 | }, | ||
| 372 | ackReady: func() bool { | 382 | ackReady: func() bool { | ||
| 373 | sess.mu.Lock() | 383 | sess.mu.Lock() | ||
| 374 | - | current := sess.client | 384 | + | attached := sess.client |
| 375 | sess.mu.Unlock() | 385 | sess.mu.Unlock() | ||
| 376 | - | if current == nil { | 386 | + | if attached == nil { |
| 377 | return false | 387 | return false | ||
| 378 | } | 388 | } | ||
| 379 | - | current.sendMu.Lock() | 389 | + | attached.sendMu.Lock() |
| 380 | - | ready := !current.output.atCapacity() | 390 | + | ready := !attached.output.atCapacity() |
| 381 | - | current.sendMu.Unlock() | 391 | + | attached.sendMu.Unlock() |
| 382 | return ready | 392 | return ready | ||
| 383 | }, | 393 | }, | ||
| 384 | }) | 394 | }) | ||
| 385 | sess.installRenderCoordinator(rc) | 395 | sess.installRenderCoordinator(rc) | ||
| 386 | } | 396 | } | ||
| 397 | + | sess.mu.Unlock() | |||
| 387 | if old != nil { | 398 | if old != nil { | ||
| 388 | - | rc.noteReplace(old, ac) | 399 | + | rc.noteReplace(old, current) |
| 400 | + | } else if current != nil { | |||
| 401 | + | rc.attach(current) | |||
| 389 | } | 402 | } | ||
| 390 | - | rc.attach(ac) | 403 | + | return rc |
| 391 | - | d.touchMRU(sess) | |||
| 392 | - | d.log.Info("client attached", "session", name, "resume", opts.resumeCapable) | |||
| 393 | - | d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true) | |||
| 394 | - | return ac, old | |||
| 395 | } | 404 | } | ||
| 396 | 405 | ||||
| 397 | // resetScreenDefaultColors clears the known default foreground/background | 406 | // resetScreenDefaultColors clears the known default foreground/background | ||
M internal/usecase/daemon/overlay_runtime.go +6 -5
| @@ -13,11 +13,12 @@ import ( | |||
| 13 | 13 | type overlayRuntime struct { | |
| 14 | 14 | ac *attachedClient | |
| 15 | 15 | ||
| 16 | - | pickerMu sync.Mutex | |
| 17 | - | picker *picker.Model | |
| 18 | - | pickerPreview *tab | |
| 19 | - | pickerPending []byte | |
| 20 | - | pickerESC pendingByteTimer | |
| 16 | + | pickerMu sync.Mutex | |
| 17 | + | picker *picker.Model | |
| 18 | + | pickerPreview *tab | |
| 19 | + | pickerPreviewSession *session | |
| 20 | + | pickerPending []byte | |
| 21 | + | pickerESC pendingByteTimer | |
| 21 | 22 | ||
| 22 | 23 | paletteMu sync.Mutex | |
| 23 | 24 | palette *palette.Model | |
| @@ -13,11 +13,12 @@ import ( | |||||
| 13 | type overlayRuntime struct { | 13 | type overlayRuntime struct { | ||
| 14 | ac *attachedClient | 14 | ac *attachedClient | ||
| 15 | 15 | ||||
| 16 | - | pickerMu sync.Mutex | 16 | + | pickerMu sync.Mutex |
| 17 | - | picker *picker.Model | 17 | + | picker *picker.Model |
| 18 | - | pickerPreview *tab | 18 | + | pickerPreview *tab |
| 19 | - | pickerPending []byte | 19 | + | pickerPreviewSession *session |
| 20 | - | pickerESC pendingByteTimer | 20 | + | pickerPending []byte |
| 21 | + | pickerESC pendingByteTimer | |||
| 21 | 22 | ||||
| 22 | paletteMu sync.Mutex | 23 | paletteMu sync.Mutex | ||
| 23 | palette *palette.Model | 24 | palette *palette.Model | ||
M internal/usecase/daemon/picker.go +67 -7
| @@ -222,21 +222,65 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||
| 222 | 222 | if ac.overlays.picker != nil { | |
| 223 | 223 | target, ok = ac.overlays.picker.Selected() | |
| 224 | 224 | } | |
| 225 | - | var next *tab | |
| 226 | - | if ok { | |
| 227 | - | next = d.tabByTarget(target) | |
| 225 | + | previous := ac.overlays.pickerPreviewSession | |
| 226 | + | ac.overlays.pickerPreviewSession = nil | |
| 227 | + | ac.overlays.pickerPreview = nil | |
| 228 | + | ac.overlays.pickerMu.Unlock() | |
| 229 | + | if previous != nil { | |
| 230 | + | if rc := previous.renderCoordinator(); rc != nil { | |
| 231 | + | rc.teardownPreviewFor(ac) | |
| 232 | + | } | |
| 228 | 233 | } | |
| 234 | + | if !ok { | |
| 235 | + | return | |
| 236 | + | } | |
| 237 | + | next := d.tabByTarget(target) | |
| 238 | + | targetSess := d.sessionByID(target.Session) | |
| 239 | + | if next == nil || targetSess == nil { | |
| 240 | + | return | |
| 241 | + | } | |
| 242 | + | ac.overlays.pickerMu.Lock() | |
| 243 | + | // Selection may have changed while the target was resolved. | |
| 229 | 244 | if ac.overlays.picker == nil { | |
| 230 | - | next = nil | |
| 245 | + | ac.overlays.pickerMu.Unlock() | |
| 246 | + | return | |
| 231 | 247 | } | |
| 232 | 248 | ac.overlays.pickerPreview = next | |
| 249 | + | ac.overlays.pickerPreviewSession = targetSess | |
| 233 | 250 | ac.overlays.pickerMu.Unlock() | |
| 251 | + | // A same-session preview is already invalidated by its own coordinator. | |
| 252 | + | // Cross-session previews subscribe to the target's producer wake instead | |
| 253 | + | // of starting a direct paint/timer path. | |
| 254 | + | if targetSess == ac.currentSession() { | |
| 255 | + | return | |
| 256 | + | } | |
| 257 | + | rc := d.attachCoordinator(targetSess, nil, nil) | |
| 258 | + | rc.subscribePreviewFor(ac, func(renderWake) { | |
| 259 | + | ac.overlays.pickerMu.Lock() | |
| 260 | + | valid := ac.overlays.pickerPreviewSession == targetSess | |
| 261 | + | ac.overlays.pickerMu.Unlock() | |
| 262 | + | if valid { | |
| 263 | + | if owner := ac.currentSession(); owner != nil { | |
| 264 | + | d.invalidateRender(owner, ac, false, "picker preview") | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | }) | |
| 234 | 268 | } | |
| 235 | 269 | ||
| 236 | 270 | func (d *Daemon) unregisterPreview(ac *attachedClient) { | |
| 271 | + | if ac == nil || ac.overlays == nil { | |
| 272 | + | return | |
| 273 | + | } | |
| 237 | 274 | ac.overlays.pickerMu.Lock() | |
| 275 | + | previous := ac.overlays.pickerPreviewSession | |
| 276 | + | ac.overlays.pickerPreviewSession = nil | |
| 238 | 277 | ac.overlays.pickerPreview = nil | |
| 239 | 278 | ac.overlays.pickerMu.Unlock() | |
| 279 | + | if previous != nil { | |
| 280 | + | if rc := previous.renderCoordinator(); rc != nil { | |
| 281 | + | rc.teardownPreviewFor(ac) | |
| 282 | + | } | |
| 283 | + | } | |
| 240 | 284 | } | |
| 241 | 285 | ||
| 242 | 286 | // clearDestroyedTabPreview clears coordinator-owned picker state for clients | |
| @@ -261,11 +305,9 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||
| 261 | 305 | } | |
| 262 | 306 | ac.overlays.pickerMu.Lock() | |
| 263 | 307 | cleared := ac.overlays.pickerPreview == tb | |
| 264 | - | if cleared { | |
| 265 | - | ac.overlays.pickerPreview = nil | |
| 266 | - | } | |
| 267 | 308 | ac.overlays.pickerMu.Unlock() | |
| 268 | 309 | if cleared { | |
| 310 | + | d.unregisterPreview(ac) | |
| 269 | 311 | if sess := ac.currentSession(); sess != nil { | |
| 270 | 312 | d.invalidateRender(sess, ac, true, "picker.go") | |
| 271 | 313 | } | |
| @@ -364,6 +406,17 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||
| 364 | 406 | ac.setSession(targetSess) | |
| 365 | 407 | ac.recordPreviousSession(from) | |
| 366 | 408 | d.mu.Unlock() | |
| 409 | + | // Both session coordinators must observe the ownership transfer before | |
| 410 | + | // stale readers/timers can publish into either session. | |
| 411 | + | if rc := from.renderCoordinator(); rc != nil { | |
| 412 | + | rc.noteDetach(ac) | |
| 413 | + | } | |
| 414 | + | // Output states from the prior session cannot be acknowledged against the | |
| 415 | + | // destination's full first frame. | |
| 416 | + | ac.sendMu.Lock() | |
| 417 | + | ac.output.rebase() | |
| 418 | + | ac.sendMu.Unlock() | |
| 419 | + | d.attachCoordinator(targetSess, old, ac) | |
| 367 | 420 | return old | |
| 368 | 421 | } | |
| 369 | 422 | ||
| @@ -402,6 +455,13 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 402 | 455 | ac.setSession(targetSess) | |
| 403 | 456 | ac.recordPreviousSession(from) | |
| 404 | 457 | d.mu.Unlock() | |
| 458 | + | if rc := from.renderCoordinator(); rc != nil { | |
| 459 | + | rc.noteDetach(ac) | |
| 460 | + | } | |
| 461 | + | ac.sendMu.Lock() | |
| 462 | + | ac.output.rebase() | |
| 463 | + | ac.sendMu.Unlock() | |
| 464 | + | d.attachCoordinator(targetSess, nil, ac) | |
| 405 | 465 | d.firstPaint(targetSess, ac, ac.size) | |
| 406 | 466 | return true | |
| 407 | 467 | } | |
| @@ -222,21 +222,65 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||||
| 222 | if ac.overlays.picker != nil { | 222 | if ac.overlays.picker != nil { | ||
| 223 | target, ok = ac.overlays.picker.Selected() | 223 | target, ok = ac.overlays.picker.Selected() | ||
| 224 | } | 224 | } | ||
| 225 | - | var next *tab | 225 | + | previous := ac.overlays.pickerPreviewSession |
| 226 | - | if ok { | 226 | + | ac.overlays.pickerPreviewSession = nil |
| 227 | - | next = d.tabByTarget(target) | 227 | + | ac.overlays.pickerPreview = nil |
| 228 | + | ac.overlays.pickerMu.Unlock() | |||
| 229 | + | if previous != nil { | |||
| 230 | + | if rc := previous.renderCoordinator(); rc != nil { | |||
| 231 | + | rc.teardownPreviewFor(ac) | |||
| 232 | + | } | |||
| 228 | } | 233 | } | ||
| 234 | + | if !ok { | |||
| 235 | + | return | |||
| 236 | + | } | |||
| 237 | + | next := d.tabByTarget(target) | |||
| 238 | + | targetSess := d.sessionByID(target.Session) | |||
| 239 | + | if next == nil || targetSess == nil { | |||
| 240 | + | return | |||
| 241 | + | } | |||
| 242 | + | ac.overlays.pickerMu.Lock() | |||
| 243 | + | // Selection may have changed while the target was resolved. | |||
| 229 | if ac.overlays.picker == nil { | 244 | if ac.overlays.picker == nil { | ||
| 230 | - | next = nil | 245 | + | ac.overlays.pickerMu.Unlock() |
| 246 | + | return | |||
| 231 | } | 247 | } | ||
| 232 | ac.overlays.pickerPreview = next | 248 | ac.overlays.pickerPreview = next | ||
| 249 | + | ac.overlays.pickerPreviewSession = targetSess | |||
| 233 | ac.overlays.pickerMu.Unlock() | 250 | ac.overlays.pickerMu.Unlock() | ||
| 251 | + | // A same-session preview is already invalidated by its own coordinator. | |||
| 252 | + | // Cross-session previews subscribe to the target's producer wake instead | |||
| 253 | + | // of starting a direct paint/timer path. | |||
| 254 | + | if targetSess == ac.currentSession() { | |||
| 255 | + | return | |||
| 256 | + | } | |||
| 257 | + | rc := d.attachCoordinator(targetSess, nil, nil) | |||
| 258 | + | rc.subscribePreviewFor(ac, func(renderWake) { | |||
| 259 | + | ac.overlays.pickerMu.Lock() | |||
| 260 | + | valid := ac.overlays.pickerPreviewSession == targetSess | |||
| 261 | + | ac.overlays.pickerMu.Unlock() | |||
| 262 | + | if valid { | |||
| 263 | + | if owner := ac.currentSession(); owner != nil { | |||
| 264 | + | d.invalidateRender(owner, ac, false, "picker preview") | |||
| 265 | + | } | |||
| 266 | + | } | |||
| 267 | + | }) | |||
| 234 | } | 268 | } | ||
| 235 | 269 | ||||
| 236 | func (d *Daemon) unregisterPreview(ac *attachedClient) { | 270 | func (d *Daemon) unregisterPreview(ac *attachedClient) { | ||
| 271 | + | if ac == nil || ac.overlays == nil { | |||
| 272 | + | return | |||
| 273 | + | } | |||
| 237 | ac.overlays.pickerMu.Lock() | 274 | ac.overlays.pickerMu.Lock() | ||
| 275 | + | previous := ac.overlays.pickerPreviewSession | |||
| 276 | + | ac.overlays.pickerPreviewSession = nil | |||
| 238 | ac.overlays.pickerPreview = nil | 277 | ac.overlays.pickerPreview = nil | ||
| 239 | ac.overlays.pickerMu.Unlock() | 278 | ac.overlays.pickerMu.Unlock() | ||
| 279 | + | if previous != nil { | |||
| 280 | + | if rc := previous.renderCoordinator(); rc != nil { | |||
| 281 | + | rc.teardownPreviewFor(ac) | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 240 | } | 284 | } | ||
| 241 | 285 | ||||
| 242 | // clearDestroyedTabPreview clears coordinator-owned picker state for clients | 286 | // clearDestroyedTabPreview clears coordinator-owned picker state for clients | ||
| @@ -261,11 +305,9 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||||
| 261 | } | 305 | } | ||
| 262 | ac.overlays.pickerMu.Lock() | 306 | ac.overlays.pickerMu.Lock() | ||
| 263 | cleared := ac.overlays.pickerPreview == tb | 307 | cleared := ac.overlays.pickerPreview == tb | ||
| 264 | - | if cleared { | |||
| 265 | - | ac.overlays.pickerPreview = nil | |||
| 266 | - | } | |||
| 267 | ac.overlays.pickerMu.Unlock() | 308 | ac.overlays.pickerMu.Unlock() | ||
| 268 | if cleared { | 309 | if cleared { | ||
| 310 | + | d.unregisterPreview(ac) | |||
| 269 | if sess := ac.currentSession(); sess != nil { | 311 | if sess := ac.currentSession(); sess != nil { | ||
| 270 | d.invalidateRender(sess, ac, true, "picker.go") | 312 | d.invalidateRender(sess, ac, true, "picker.go") | ||
| 271 | } | 313 | } | ||
| @@ -364,6 +406,17 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||||
| 364 | ac.setSession(targetSess) | 406 | ac.setSession(targetSess) | ||
| 365 | ac.recordPreviousSession(from) | 407 | ac.recordPreviousSession(from) | ||
| 366 | d.mu.Unlock() | 408 | d.mu.Unlock() | ||
| 409 | + | // Both session coordinators must observe the ownership transfer before | |||
| 410 | + | // stale readers/timers can publish into either session. | |||
| 411 | + | if rc := from.renderCoordinator(); rc != nil { | |||
| 412 | + | rc.noteDetach(ac) | |||
| 413 | + | } | |||
| 414 | + | // Output states from the prior session cannot be acknowledged against the | |||
| 415 | + | // destination's full first frame. | |||
| 416 | + | ac.sendMu.Lock() | |||
| 417 | + | ac.output.rebase() | |||
| 418 | + | ac.sendMu.Unlock() | |||
| 419 | + | d.attachCoordinator(targetSess, old, ac) | |||
| 367 | return old | 420 | return old | ||
| 368 | } | 421 | } | ||
| 369 | 422 | ||||
| @@ -402,6 +455,13 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 402 | ac.setSession(targetSess) | 455 | ac.setSession(targetSess) | ||
| 403 | ac.recordPreviousSession(from) | 456 | ac.recordPreviousSession(from) | ||
| 404 | d.mu.Unlock() | 457 | d.mu.Unlock() | ||
| 458 | + | if rc := from.renderCoordinator(); rc != nil { | |||
| 459 | + | rc.noteDetach(ac) | |||
| 460 | + | } | |||
| 461 | + | ac.sendMu.Lock() | |||
| 462 | + | ac.output.rebase() | |||
| 463 | + | ac.sendMu.Unlock() | |||
| 464 | + | d.attachCoordinator(targetSess, nil, ac) | |||
| 405 | d.firstPaint(targetSess, ac, ac.size) | 465 | d.firstPaint(targetSess, ac, ac.size) | ||
| 406 | return true | 466 | return true | ||
| 407 | } | 467 | } | ||
M internal/usecase/daemon/render_coordinator.go +45 -4
| @@ -103,9 +103,11 @@ type renderCoordinator struct { | |||
| 103 | 103 | pendingUrgent bool | |
| 104 | 104 | coalesced int | |
| 105 | 105 | ||
| 106 | - | // previewWake receives the same coalesced wakes while a preview | |
| 107 | - | // subscription is installed. | |
| 108 | - | previewWake func(renderWake) | |
| 106 | + | // previewWake receives the same coalesced wakes for the legacy single | |
| 107 | + | // subscriber. previewWakes tracks picker subscriptions by viewer, so one | |
| 108 | + | // inactive session cannot replace another viewer's live preview. | |
| 109 | + | previewWake func(renderWake) | |
| 110 | + | previewWakes map[*attachedClient]func(renderWake) | |
| 109 | 111 | ||
| 110 | 112 | // attachment is the currently bound client identity; callbacks from any | |
| 111 | 113 | // other identity are stale and must not mutate coordinator state. | |
| @@ -208,6 +210,13 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) { | |||
| 208 | 210 | case <-cancel: | |
| 209 | 211 | } | |
| 210 | 212 | }() | |
| 213 | + | // A nil timer channel is an inert test clock, not a schedulable deadline. | |
| 214 | + | // Complete through the coordinator so manual daemon fixtures retain their | |
| 215 | + | // deterministic synchronous contract without a direct paint fallback. | |
| 216 | + | if timer.C() == nil { | |
| 217 | + | c.fire(gen, false) | |
| 218 | + | return | |
| 219 | + | } | |
| 211 | 220 | // Give fake-clock callbacks a chance to block on C before the test | |
| 212 | 221 | // advances it; real clocks are unaffected. | |
| 213 | 222 | runtime.Gosched() | |
| @@ -294,9 +303,32 @@ func (c *renderCoordinator) subscribePreview(fn func(renderWake)) { | |||
| 294 | 303 | c.mu.Unlock() | |
| 295 | 304 | } | |
| 296 | 305 | ||
| 297 | - | // teardownPreview removes the preview subscription. | |
| 306 | + | // teardownPreview removes the legacy preview subscription. | |
| 298 | 307 | func (c *renderCoordinator) teardownPreview() { c.mu.Lock(); c.previewWake = nil; c.mu.Unlock() } | |
| 299 | 308 | ||
| 309 | + | // subscribePreviewFor installs the dynamic picker observer owned by viewer. | |
| 310 | + | // The owner key makes target changes, detach, and session teardown precise. | |
| 311 | + | func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, fn func(renderWake)) { | |
| 312 | + | if viewer == nil || fn == nil { | |
| 313 | + | return | |
| 314 | + | } | |
| 315 | + | c.mu.Lock() | |
| 316 | + | if !c.torndown { | |
| 317 | + | if c.previewWakes == nil { | |
| 318 | + | c.previewWakes = make(map[*attachedClient]func(renderWake)) | |
| 319 | + | } | |
| 320 | + | c.previewWakes[viewer] = fn | |
| 321 | + | } | |
| 322 | + | c.mu.Unlock() | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | // teardownPreviewFor removes only viewer's dynamic picker observer. | |
| 326 | + | func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient) { | |
| 327 | + | c.mu.Lock() | |
| 328 | + | delete(c.previewWakes, viewer) | |
| 329 | + | c.mu.Unlock() | |
| 330 | + | } | |
| 331 | + | ||
| 300 | 332 | // attach binds ac as the coordinator's current attachment identity. | |
| 301 | 333 | func (c *renderCoordinator) attach(ac *attachedClient) { | |
| 302 | 334 | c.mu.Lock() | |
| @@ -344,6 +376,8 @@ func (c *renderCoordinator) noteSessionTeardown() { | |||
| 344 | 376 | c.mu.Lock() | |
| 345 | 377 | c.torndown = true | |
| 346 | 378 | c.attachment = nil | |
| 379 | + | c.previewWake = nil | |
| 380 | + | c.previewWakes = nil | |
| 347 | 381 | c.pending = false | |
| 348 | 382 | c.generation++ | |
| 349 | 383 | c.armed = false | |
| @@ -438,6 +472,10 @@ func (c *renderCoordinator) fire(gen uint64, watchdog bool) { | |||
| 438 | 472 | c.cancelNormalTimerLocked() | |
| 439 | 473 | c.pending, c.pendingReset, c.pendingUrgent, c.coalesced, c.armed = false, false, false, 0, false | |
| 440 | 474 | wake, preview := c.opts.wake, c.previewWake | |
| 475 | + | previews := make([]func(renderWake), 0, len(c.previewWakes)) | |
| 476 | + | for _, fn := range c.previewWakes { | |
| 477 | + | previews = append(previews, fn) | |
| 478 | + | } | |
| 441 | 479 | c.mu.Unlock() | |
| 442 | 480 | if wake != nil { | |
| 443 | 481 | wake(w) | |
| @@ -445,4 +483,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog bool) { | |||
| 445 | 483 | if preview != nil { | |
| 446 | 484 | preview(w) | |
| 447 | 485 | } | |
| 486 | + | for _, fn := range previews { | |
| 487 | + | fn(w) | |
| 488 | + | } | |
| 448 | 489 | } | |
| @@ -103,9 +103,11 @@ type renderCoordinator struct { | |||||
| 103 | pendingUrgent bool | 103 | pendingUrgent bool | ||
| 104 | coalesced int | 104 | coalesced int | ||
| 105 | 105 | ||||
| 106 | - | // previewWake receives the same coalesced wakes while a preview | 106 | + | // previewWake receives the same coalesced wakes for the legacy single |
| 107 | - | // subscription is installed. | 107 | + | // subscriber. previewWakes tracks picker subscriptions by viewer, so one |
| 108 | - | previewWake func(renderWake) | 108 | + | // inactive session cannot replace another viewer's live preview. |
| 109 | + | previewWake func(renderWake) | |||
| 110 | + | previewWakes map[*attachedClient]func(renderWake) | |||
| 109 | 111 | ||||
| 110 | // attachment is the currently bound client identity; callbacks from any | 112 | // attachment is the currently bound client identity; callbacks from any | ||
| 111 | // other identity are stale and must not mutate coordinator state. | 113 | // other identity are stale and must not mutate coordinator state. | ||
| @@ -208,6 +210,13 @@ func (c *renderCoordinator) invalidate(inv renderInvalidation) { | |||||
| 208 | case <-cancel: | 210 | case <-cancel: | ||
| 209 | } | 211 | } | ||
| 210 | }() | 212 | }() | ||
| 213 | + | // A nil timer channel is an inert test clock, not a schedulable deadline. | |||
| 214 | + | // Complete through the coordinator so manual daemon fixtures retain their | |||
| 215 | + | // deterministic synchronous contract without a direct paint fallback. | |||
| 216 | + | if timer.C() == nil { | |||
| 217 | + | c.fire(gen, false) | |||
| 218 | + | return | |||
| 219 | + | } | |||
| 211 | // Give fake-clock callbacks a chance to block on C before the test | 220 | // Give fake-clock callbacks a chance to block on C before the test | ||
| 212 | // advances it; real clocks are unaffected. | 221 | // advances it; real clocks are unaffected. | ||
| 213 | runtime.Gosched() | 222 | runtime.Gosched() | ||
| @@ -294,9 +303,32 @@ func (c *renderCoordinator) subscribePreview(fn func(renderWake)) { | |||||
| 294 | c.mu.Unlock() | 303 | c.mu.Unlock() | ||
| 295 | } | 304 | } | ||
| 296 | 305 | ||||
| 297 | - | // teardownPreview removes the preview subscription. | 306 | + | // teardownPreview removes the legacy preview subscription. |
| 298 | func (c *renderCoordinator) teardownPreview() { c.mu.Lock(); c.previewWake = nil; c.mu.Unlock() } | 307 | func (c *renderCoordinator) teardownPreview() { c.mu.Lock(); c.previewWake = nil; c.mu.Unlock() } | ||
| 299 | 308 | ||||
| 309 | + | // subscribePreviewFor installs the dynamic picker observer owned by viewer. | |||
| 310 | + | // The owner key makes target changes, detach, and session teardown precise. | |||
| 311 | + | func (c *renderCoordinator) subscribePreviewFor(viewer *attachedClient, fn func(renderWake)) { | |||
| 312 | + | if viewer == nil || fn == nil { | |||
| 313 | + | return | |||
| 314 | + | } | |||
| 315 | + | c.mu.Lock() | |||
| 316 | + | if !c.torndown { | |||
| 317 | + | if c.previewWakes == nil { | |||
| 318 | + | c.previewWakes = make(map[*attachedClient]func(renderWake)) | |||
| 319 | + | } | |||
| 320 | + | c.previewWakes[viewer] = fn | |||
| 321 | + | } | |||
| 322 | + | c.mu.Unlock() | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | // teardownPreviewFor removes only viewer's dynamic picker observer. | |||
| 326 | + | func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient) { | |||
| 327 | + | c.mu.Lock() | |||
| 328 | + | delete(c.previewWakes, viewer) | |||
| 329 | + | c.mu.Unlock() | |||
| 330 | + | } | |||
| 331 | + | ||||
| 300 | // attach binds ac as the coordinator's current attachment identity. | 332 | // attach binds ac as the coordinator's current attachment identity. | ||
| 301 | func (c *renderCoordinator) attach(ac *attachedClient) { | 333 | func (c *renderCoordinator) attach(ac *attachedClient) { | ||
| 302 | c.mu.Lock() | 334 | c.mu.Lock() | ||
| @@ -344,6 +376,8 @@ func (c *renderCoordinator) noteSessionTeardown() { | |||||
| 344 | c.mu.Lock() | 376 | c.mu.Lock() | ||
| 345 | c.torndown = true | 377 | c.torndown = true | ||
| 346 | c.attachment = nil | 378 | c.attachment = nil | ||
| 379 | + | c.previewWake = nil | |||
| 380 | + | c.previewWakes = nil | |||
| 347 | c.pending = false | 381 | c.pending = false | ||
| 348 | c.generation++ | 382 | c.generation++ | ||
| 349 | c.armed = false | 383 | c.armed = false | ||
| @@ -438,6 +472,10 @@ func (c *renderCoordinator) fire(gen uint64, watchdog bool) { | |||||
| 438 | c.cancelNormalTimerLocked() | 472 | c.cancelNormalTimerLocked() | ||
| 439 | c.pending, c.pendingReset, c.pendingUrgent, c.coalesced, c.armed = false, false, false, 0, false | 473 | c.pending, c.pendingReset, c.pendingUrgent, c.coalesced, c.armed = false, false, false, 0, false | ||
| 440 | wake, preview := c.opts.wake, c.previewWake | 474 | wake, preview := c.opts.wake, c.previewWake | ||
| 475 | + | previews := make([]func(renderWake), 0, len(c.previewWakes)) | |||
| 476 | + | for _, fn := range c.previewWakes { | |||
| 477 | + | previews = append(previews, fn) | |||
| 478 | + | } | |||
| 441 | c.mu.Unlock() | 479 | c.mu.Unlock() | ||
| 442 | if wake != nil { | 480 | if wake != nil { | ||
| 443 | wake(w) | 481 | wake(w) | ||
| @@ -445,4 +483,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog bool) { | |||||
| 445 | if preview != nil { | 483 | if preview != nil { | ||
| 446 | preview(w) | 484 | preview(w) | ||
| 447 | } | 485 | } | ||
| 486 | + | for _, fn := range previews { | |||
| 487 | + | fn(w) | |||
| 488 | + | } | |||
| 448 | } | 489 | } | ||
M internal/usecase/daemon/resume.go +1 -0
| @@ -187,6 +187,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 187 | 187 | sess.client = ac | |
| 188 | 188 | sess.terminal = terminalEnv{TrueColor: h.TrueColor} | |
| 189 | 189 | sess.mu.Unlock() | |
| 190 | + | d.attachCoordinator(sess, nil, ac) | |
| 190 | 191 | d.touchMRU(sess) | |
| 191 | 192 | d.log.Info("client resumed", "session", sess.name) | |
| 192 | 193 | return sess, ac, true, nil | |
| @@ -187,6 +187,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||||
| 187 | sess.client = ac | 187 | sess.client = ac | ||
| 188 | sess.terminal = terminalEnv{TrueColor: h.TrueColor} | 188 | sess.terminal = terminalEnv{TrueColor: h.TrueColor} | ||
| 189 | sess.mu.Unlock() | 189 | sess.mu.Unlock() | ||
| 190 | + | d.attachCoordinator(sess, nil, ac) | |||
| 190 | d.touchMRU(sess) | 191 | d.touchMRU(sess) | ||
| 191 | d.log.Info("client resumed", "session", sess.name) | 192 | d.log.Info("client resumed", "session", sess.name) | ||
| 192 | return sess, ac, true, nil | 193 | return sess, ac, true, nil | ||