detached 5c0dde8

fix(daemon): prepare coordinator ownership before publication

commit
5c0dde823a4226494a5cee9b270eecd2f40c320c
author
brice · · 2026-07-11 20:02 UTC
parent
7c1f5df
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +39 -21
M internal/usecase/daemon/client.go +9 -4
@@ -350,21 +350,26 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
350350 ac.initOverlays()
351351 ac.setSession(sess)
352352 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
353+ // Daemon attachment callers hold d.mu, serialising this preparation with
354+ // other publications. Bind coordinator ownership before sess.client becomes
355+ // visible: an old deadline can then never target this new output chain.
353356 sess.mu.Lock()
354357 old := sess.client
355- sess.client = ac
356358 name := sess.name
357359 sess.mu.Unlock()
358360 d.attachCoordinator(sess, old, ac)
361+ sess.mu.Lock()
362+ sess.client = ac
363+ sess.mu.Unlock()
359364 d.touchMRU(sess)
360365 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
361366 d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
362367 return ac, old
363368 }
364369
365-// handoffCoordinator completes a cross-session ownership transfer before the
366-// destination's first paint. It invalidates source callbacks, rebases the
367-// attachment's output dependency chain, then installs the destination owner.
370+// handoffCoordinator prepares a cross-session ownership transfer before the
371+// destination publishes sess.client. It never takes daemon/session locks while
372+// taking sendMu, avoiding a d.mu/sendMu cycle with transport callbacks.
368373 func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) {
369374 if rc := from.renderCoordinator(); rc != nil {
370375 rc.noteDetach(current)
@@ -350,21 +350,26 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
350 ac.initOverlays()350 ac.initOverlays()
351 ac.setSession(sess)351 ac.setSession(sess)
352 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)352 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
353+ // Daemon attachment callers hold d.mu, serialising this preparation with
354+ // other publications. Bind coordinator ownership before sess.client becomes
355+ // visible: an old deadline can then never target this new output chain.
353 sess.mu.Lock()356 sess.mu.Lock()
354 old := sess.client357 old := sess.client
355- sess.client = ac
356 name := sess.name358 name := sess.name
357 sess.mu.Unlock()359 sess.mu.Unlock()
358 d.attachCoordinator(sess, old, ac)360 d.attachCoordinator(sess, old, ac)
361+ sess.mu.Lock()
362+ sess.client = ac
363+ sess.mu.Unlock()
359 d.touchMRU(sess)364 d.touchMRU(sess)
360 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)365 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
361 d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)366 d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
362 return ac, old367 return ac, old
363 }368 }
364 369
365-// handoffCoordinator completes a cross-session ownership transfer before the370+// handoffCoordinator prepares a cross-session ownership transfer before the
366-// destination's first paint. It invalidates source callbacks, rebases the371+// destination publishes sess.client. It never takes daemon/session locks while
367-// attachment's output dependency chain, then installs the destination owner.372+// taking sendMu, avoiding a d.mu/sendMu cycle with transport callbacks.
368 func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) {373 func (d *Daemon) handoffCoordinator(from, target *session, old, current *attachedClient) {
369 if rc := from.renderCoordinator(); rc != nil {374 if rc := from.renderCoordinator(); rc != nil {
370 rc.noteDetach(current)375 rc.noteDetach(current)
M internal/usecase/daemon/picker.go +10 -6
@@ -396,17 +396,21 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
396396 targetSess.mu.Lock()
397397 old := targetSess.client
398398 targetSess.terminal = term
399- targetSess.client = ac
400399 if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
401400 targetSess.active = target.TabIndex
402401 }
403402 targetSess.mu.Unlock()
404403
405- d.touchMRU(targetSess)
404+ // Do not expose targetSess.client until the target coordinator has claimed
405+ // this attachment and the output dependency chain has been rebased.
406+ d.handoffCoordinator(from, targetSess, old, ac)
406407 ac.setSession(targetSess)
408+ targetSess.mu.Lock()
409+ targetSess.client = ac
410+ targetSess.mu.Unlock()
411+ d.touchMRU(targetSess)
407412 ac.recordPreviousSession(from)
408413 d.mu.Unlock()
409- d.handoffCoordinator(from, targetSess, old, ac)
410414 if old != nil && old != ac {
411415 // Displacement is a detach lifecycle transition too. Cancel the
412416 // attachment-owned PR #71 timer only after releasing daemon/session
@@ -443,15 +447,15 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
443447 from.client = nil
444448 ac.setSession(nil)
445449 from.mu.Unlock()
450+
451+ d.handoffCoordinator(from, targetSess, nil, ac)
452+ ac.setSession(targetSess)
446453 targetSess.mu.Lock()
447454 targetSess.client = ac
448455 targetSess.mu.Unlock()
449-
450456 d.touchMRU(targetSess)
451- ac.setSession(targetSess)
452457 ac.recordPreviousSession(from)
453458 d.mu.Unlock()
454- d.handoffCoordinator(from, targetSess, nil, ac)
455459 d.firstPaint(targetSess, ac, ac.size)
456460 return true
457461 }
@@ -396,17 +396,21 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
396 targetSess.mu.Lock()396 targetSess.mu.Lock()
397 old := targetSess.client397 old := targetSess.client
398 targetSess.terminal = term398 targetSess.terminal = term
399- targetSess.client = ac
400 if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {399 if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
401 targetSess.active = target.TabIndex400 targetSess.active = target.TabIndex
402 }401 }
403 targetSess.mu.Unlock()402 targetSess.mu.Unlock()
404 403
405- d.touchMRU(targetSess)404+ // Do not expose targetSess.client until the target coordinator has claimed
405+ // this attachment and the output dependency chain has been rebased.
406+ d.handoffCoordinator(from, targetSess, old, ac)
406 ac.setSession(targetSess)407 ac.setSession(targetSess)
408+ targetSess.mu.Lock()
409+ targetSess.client = ac
410+ targetSess.mu.Unlock()
411+ d.touchMRU(targetSess)
407 ac.recordPreviousSession(from)412 ac.recordPreviousSession(from)
408 d.mu.Unlock()413 d.mu.Unlock()
409- d.handoffCoordinator(from, targetSess, old, ac)
410 if old != nil && old != ac {414 if old != nil && old != ac {
411 // Displacement is a detach lifecycle transition too. Cancel the415 // Displacement is a detach lifecycle transition too. Cancel the
412 // attachment-owned PR #71 timer only after releasing daemon/session416 // attachment-owned PR #71 timer only after releasing daemon/session
@@ -443,15 +447,15 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
443 from.client = nil447 from.client = nil
444 ac.setSession(nil)448 ac.setSession(nil)
445 from.mu.Unlock()449 from.mu.Unlock()
450+
451+ d.handoffCoordinator(from, targetSess, nil, ac)
452+ ac.setSession(targetSess)
446 targetSess.mu.Lock()453 targetSess.mu.Lock()
447 targetSess.client = ac454 targetSess.client = ac
448 targetSess.mu.Unlock()455 targetSess.mu.Unlock()
449-
450 d.touchMRU(targetSess)456 d.touchMRU(targetSess)
451- ac.setSession(targetSess)
452 ac.recordPreviousSession(from)457 ac.recordPreviousSession(from)
453 d.mu.Unlock()458 d.mu.Unlock()
454- d.handoffCoordinator(from, targetSess, nil, ac)
455 d.firstPaint(targetSess, ac, ac.size)459 d.firstPaint(targetSess, ac, ac.size)
456 return true460 return true
457 }461 }
M internal/usecase/daemon/render_coordinator.go +16 -6
@@ -230,12 +230,22 @@ func stopTimers(timers []ports.Timer) {
230230 }
231231 }
232232
233-// invalidate publishes one producer state transition. Consecutive
234-// invalidations coalesce under a single armed deadline; the latest state and
235-// the stickiest reset win.
233+// invalidate publishes a coordinator-owned producer transition. Callers that
234+// carry an attachment identity must use invalidateForAttachment instead.
236235 func (c *renderCoordinator) invalidate(inv renderInvalidation) {
236+ c.invalidateForAttachment(nil, inv)
237+}
238+
239+// invalidateForAttachment publishes only while source is still the bound
240+// attachment. This check shares c.mu with replacement, making a stale
241+// attachment's timer callback unable to enqueue work for its replacement.
242+// A nil source represents an internal pane/session invalidation.
243+func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) {
237244 c.mu.Lock()
238- if c.torndown || c.detached {
245+ // An unbound coordinator is the manual/headless harness state; there is no
246+ // published production attachment to protect there. Once an attachment has
247+ // been bound (or detached), identity must match exactly.
248+ if c.torndown || c.detached || (source != nil && c.attachment != nil && c.attachment != source) {
239249 c.mu.Unlock()
240250 return
241251 }
@@ -646,7 +656,7 @@ func (c *renderCoordinator) fireCurrent(watchdog bool) {
646656 func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool, producer string) {
647657 if sess != nil {
648658 if rc := sess.renderCoordinator(); rc != nil {
649- rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
659+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
650660 return
651661 }
652662 }
@@ -661,7 +671,7 @@ func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool,
661671 func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bool, producer string) {
662672 if sess != nil {
663673 if rc := sess.renderCoordinator(); rc != nil {
664- rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
674+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
665675 rc.fireCurrent(false)
666676 return
667677 }
@@ -230,12 +230,22 @@ func stopTimers(timers []ports.Timer) {
230 }230 }
231 }231 }
232 232
233-// invalidate publishes one producer state transition. Consecutive233+// invalidate publishes a coordinator-owned producer transition. Callers that
234-// invalidations coalesce under a single armed deadline; the latest state and234+// carry an attachment identity must use invalidateForAttachment instead.
235-// the stickiest reset win.
236 func (c *renderCoordinator) invalidate(inv renderInvalidation) {235 func (c *renderCoordinator) invalidate(inv renderInvalidation) {
236+ c.invalidateForAttachment(nil, inv)
237+}
238+
239+// invalidateForAttachment publishes only while source is still the bound
240+// attachment. This check shares c.mu with replacement, making a stale
241+// attachment's timer callback unable to enqueue work for its replacement.
242+// A nil source represents an internal pane/session invalidation.
243+func (c *renderCoordinator) invalidateForAttachment(source *attachedClient, inv renderInvalidation) {
237 c.mu.Lock()244 c.mu.Lock()
238- if c.torndown || c.detached {245+ // An unbound coordinator is the manual/headless harness state; there is no
246+ // published production attachment to protect there. Once an attachment has
247+ // been bound (or detached), identity must match exactly.
248+ if c.torndown || c.detached || (source != nil && c.attachment != nil && c.attachment != source) {
239 c.mu.Unlock()249 c.mu.Unlock()
240 return250 return
241 }251 }
@@ -646,7 +656,7 @@ func (c *renderCoordinator) fireCurrent(watchdog bool) {
646 func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool, producer string) {656 func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool, producer string) {
647 if sess != nil {657 if sess != nil {
648 if rc := sess.renderCoordinator(); rc != nil {658 if rc := sess.renderCoordinator(); rc != nil {
649- rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})659+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
650 return660 return
651 }661 }
652 }662 }
@@ -661,7 +671,7 @@ func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool,
661 func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bool, producer string) {671 func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bool, producer string) {
662 if sess != nil {672 if sess != nil {
663 if rc := sess.renderCoordinator(); rc != nil {673 if rc := sess.renderCoordinator(); rc != nil {
664- rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})674+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
665 rc.fireCurrent(false)675 rc.fireCurrent(false)
666 return676 return
667 }677 }
M internal/usecase/daemon/session.go +4 -5
@@ -237,19 +237,18 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
237237 ac.setSession(nil)
238238 from.mu.Unlock()
239239
240+ // Prepare source invalidation, output rebase, and destination coordinator
241+ // identity before publishing the destination attachment.
242+ d.handoffCoordinator(from, newSess, nil, ac)
243+ ac.setSession(newSess)
240244 newSess.mu.Lock()
241245 newSess.client = ac
242246 newSess.mu.Unlock()
243247
244248 d.touchMRU(newSess)
245- ac.setSession(newSess)
246249 ac.recordPreviousSession(from)
247250 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
248251 d.mu.Unlock()
249-
250- // Match every other cross-session path: source callbacks are stale and
251- // destination ownership is installed before its first full frame.
252- d.handoffCoordinator(from, newSess, nil, ac)
253252 d.firstPaint(newSess, ac, sz)
254253 return nil
255254 }
@@ -237,19 +237,18 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
237 ac.setSession(nil)237 ac.setSession(nil)
238 from.mu.Unlock()238 from.mu.Unlock()
239 239
240+ // Prepare source invalidation, output rebase, and destination coordinator
241+ // identity before publishing the destination attachment.
242+ d.handoffCoordinator(from, newSess, nil, ac)
243+ ac.setSession(newSess)
240 newSess.mu.Lock()244 newSess.mu.Lock()
241 newSess.client = ac245 newSess.client = ac
242 newSess.mu.Unlock()246 newSess.mu.Unlock()
243 247
244 d.touchMRU(newSess)248 d.touchMRU(newSess)
245- ac.setSession(newSess)
246 ac.recordPreviousSession(from)249 ac.recordPreviousSession(from)
247 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)250 d.log.Info("client attached", "session", newSess.name, "resume", ac.resumeCapable)
248 d.mu.Unlock()251 d.mu.Unlock()
249-
250- // Match every other cross-session path: source callbacks are stale and
251- // destination ownership is installed before its first full frame.
252- d.handoffCoordinator(from, newSess, nil, ac)
253 d.firstPaint(newSess, ac, sz)252 d.firstPaint(newSess, ac, sz)
254 return nil253 return nil
255 }254 }

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close