detached de2ad79

fix(daemon): bind coordinator wakes to attachments

commit
de2ad79f93cf86ab9eb7720910f6296077331496
author
brice · · 2026-07-11 19:47 UTC
parent
9a215e4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +45 -11
M internal/usecase/daemon/client.go +6 -5
@@ -385,11 +385,12 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient)
385385 rc = newRenderCoordinator(renderCoordinatorOptions{
386386 clock: d.clock,
387387 wake: func(w renderWake) {
388- sess.mu.Lock()
389- attached := sess.client
390- sess.mu.Unlock()
391- if attached != nil {
392- d.paint(sess, attached, w.reset)
388+ // w.attachment was validated and captured under the coordinator
389+ // lock. Never reread sess.client here: attach publishes that field
390+ // before it replaces the coordinator, so doing so can paint the
391+ // new connection before Welcome.
392+ if w.attachment != nil {
393+ d.paint(sess, w.attachment, w.reset)
393394 }
394395 },
395396 ackReady: func() bool {
@@ -385,11 +385,12 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient)
385 rc = newRenderCoordinator(renderCoordinatorOptions{385 rc = newRenderCoordinator(renderCoordinatorOptions{
386 clock: d.clock,386 clock: d.clock,
387 wake: func(w renderWake) {387 wake: func(w renderWake) {
388- sess.mu.Lock()388+ // w.attachment was validated and captured under the coordinator
389- attached := sess.client389+ // lock. Never reread sess.client here: attach publishes that field
390- sess.mu.Unlock()390+ // before it replaces the coordinator, so doing so can paint the
391- if attached != nil {391+ // new connection before Welcome.
392- d.paint(sess, attached, w.reset)392+ if w.attachment != nil {
393+ d.paint(sess, w.attachment, w.reset)
393 }394 }
394 },395 },
395 ackReady: func() bool {396 ackReady: func() bool {
M internal/usecase/daemon/picker.go +6 -0
@@ -407,6 +407,12 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
407407 ac.recordPreviousSession(from)
408408 d.mu.Unlock()
409409 d.handoffCoordinator(from, targetSess, old, ac)
410+ if old != nil && old != ac {
411+ // Displacement is a detach lifecycle transition too. Cancel the
412+ // attachment-owned PR #71 timer only after releasing daemon/session
413+ // locks, preserving sendMu > daemon/session lock ordering.
414+ old.cancelResizePaint()
415+ }
410416 return old
411417 }
412418
@@ -407,6 +407,12 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
407 ac.recordPreviousSession(from)407 ac.recordPreviousSession(from)
408 d.mu.Unlock()408 d.mu.Unlock()
409 d.handoffCoordinator(from, targetSess, old, ac)409 d.handoffCoordinator(from, targetSess, old, ac)
410+ if old != nil && old != ac {
411+ // Displacement is a detach lifecycle transition too. Cancel the
412+ // attachment-owned PR #71 timer only after releasing daemon/session
413+ // locks, preserving sendMu > daemon/session lock ordering.
414+ old.cancelResizePaint()
415+ }
410 return old416 return old
411 }417 }
412 418
M internal/usecase/daemon/render.go +7 -1
@@ -288,7 +288,13 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
288288 }
289289
290290 ac.sendMu.Lock()
291- if ac.currentSession() != sess {
291+ // sendMu is the attachment ownership boundary. Check the session's
292+ // published identity while holding it so a deadline captured before an
293+ // attach/replace cannot emit on either the old or new output chain.
294+ sess.mu.Lock()
295+ owned := sess.client == ac
296+ sess.mu.Unlock()
297+ if !owned || ac.currentSession() != sess {
292298 ac.sendMu.Unlock()
293299 return
294300 }
@@ -288,7 +288,13 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
288 }288 }
289 289
290 ac.sendMu.Lock()290 ac.sendMu.Lock()
291- if ac.currentSession() != sess {291+ // sendMu is the attachment ownership boundary. Check the session's
292+ // published identity while holding it so a deadline captured before an
293+ // attach/replace cannot emit on either the old or new output chain.
294+ sess.mu.Lock()
295+ owned := sess.client == ac
296+ sess.mu.Unlock()
297+ if !owned || ac.currentSession() != sess {
292 ac.sendMu.Unlock()298 ac.sendMu.Unlock()
293 return299 return
294 }300 }
M internal/usecase/daemon/render_coordinator.go +10 -2
@@ -54,6 +54,10 @@ type renderWake struct {
5454 reset bool
5555 urgent bool
5656 coalesced int
57+ // attachment is snapshotted under coordinator ownership. The wake
58+ // consumer must compose only for this exact client, never by rereading
59+ // session.client after an attach publication.
60+ attachment *attachedClient
5761 // watchdog marks a flush forced by the synchronized-output watchdog.
5862 watchdog bool
5963 }
@@ -510,7 +514,11 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
510514 // captured by old become stale.
511515 func (c *renderCoordinator) noteReplace(old, replacement *attachedClient) {
512516 c.mu.Lock()
513- if c.attachment == old {
517+ // A coordinator may be installed while replacing a legacy attachment
518+ // which predated coordinator ownership. In that case nil has no pending
519+ // identity to invalidate, and the replacement becomes the first bound
520+ // attachment atomically with this lifecycle transition.
521+ if c.attachment == old || c.attachment == nil {
514522 c.attachment = replacement
515523 c.pending = false
516524 c.ackDeferred = false
@@ -647,7 +655,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
647655 c.mu.Unlock()
648656 return
649657 }
650- w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog}
658+ w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, attachment: c.attachment}
651659 if !ready {
652660 if deadline {
653661 c.ackDeferred = true
@@ -54,6 +54,10 @@ type renderWake struct {
54 reset bool54 reset bool
55 urgent bool55 urgent bool
56 coalesced int56 coalesced int
57+ // attachment is snapshotted under coordinator ownership. The wake
58+ // consumer must compose only for this exact client, never by rereading
59+ // session.client after an attach publication.
60+ attachment *attachedClient
57 // watchdog marks a flush forced by the synchronized-output watchdog.61 // watchdog marks a flush forced by the synchronized-output watchdog.
58 watchdog bool62 watchdog bool
59 }63 }
@@ -510,7 +514,11 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
510 // captured by old become stale.514 // captured by old become stale.
511 func (c *renderCoordinator) noteReplace(old, replacement *attachedClient) {515 func (c *renderCoordinator) noteReplace(old, replacement *attachedClient) {
512 c.mu.Lock()516 c.mu.Lock()
513- if c.attachment == old {517+ // A coordinator may be installed while replacing a legacy attachment
518+ // which predated coordinator ownership. In that case nil has no pending
519+ // identity to invalidate, and the replacement becomes the first bound
520+ // attachment atomically with this lifecycle transition.
521+ if c.attachment == old || c.attachment == nil {
514 c.attachment = replacement522 c.attachment = replacement
515 c.pending = false523 c.pending = false
516 c.ackDeferred = false524 c.ackDeferred = false
@@ -647,7 +655,7 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
647 c.mu.Unlock()655 c.mu.Unlock()
648 return656 return
649 }657 }
650- w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog}658+ w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, attachment: c.attachment}
651 if !ready {659 if !ready {
652 if deadline {660 if deadline {
653 c.ackDeferred = true661 c.ackDeferred = true
M internal/usecase/daemon/render_coordinator_test.go +16 -3
@@ -354,7 +354,10 @@ resize:
354354 select {
355355 case epoch := <-resizeDone:
356356 require.Equal(t, uint64(1), epoch)
357- require.Equal(t, renderWake{coalesced: 1}, awaitWake(t, wakes))
357+ wake := awaitWake(t, wakes)
358+ require.Same(t, owner, wake.attachment)
359+ wake.attachment = nil
360+ require.Equal(t, renderWake{coalesced: 1}, wake)
358361 for range 4096 {
359362 select {
360363 case <-fireDone:
@@ -737,14 +740,24 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
737740 require.Len(t, timers, 1)
738741 timers[0].ch <- time.Time{}
739742
740- require.Equal(t, renderWake{coalesced: 1}, awaitWake(t, h.previews),
743+ preview := awaitWake(t, h.previews)
744+ if tc.attach {
745+ require.NotNil(t, preview.attachment)
746+ }
747+ preview.attachment = nil
748+ require.Equal(t, renderWake{coalesced: 1}, preview,
741749 "the viewer preview must receive target output without target ACK capacity")
742750 requireNoWake(t, h.wakes)
743751 requireNoWake(t, h.previews)
744752
745753 h.ackReady.Store(true)
746754 h.rc.notifyAck()
747- require.Equal(t, renderWake{coalesced: 1}, awaitWake(t, h.wakes),
755+ wake := awaitWake(t, h.wakes)
756+ if tc.attach {
757+ require.NotNil(t, wake.attachment)
758+ }
759+ wake.attachment = nil
760+ require.Equal(t, renderWake{coalesced: 1}, wake,
748761 "the target primary frame remains pending for its own ACK")
749762 requireNoWake(t, h.previews)
750763 })
@@ -354,7 +354,10 @@ resize:
354 select {354 select {
355 case epoch := <-resizeDone:355 case epoch := <-resizeDone:
356 require.Equal(t, uint64(1), epoch)356 require.Equal(t, uint64(1), epoch)
357- require.Equal(t, renderWake{coalesced: 1}, awaitWake(t, wakes))357+ wake := awaitWake(t, wakes)
358+ require.Same(t, owner, wake.attachment)
359+ wake.attachment = nil
360+ require.Equal(t, renderWake{coalesced: 1}, wake)
358 for range 4096 {361 for range 4096 {
359 select {362 select {
360 case <-fireDone:363 case <-fireDone:
@@ -737,14 +740,24 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
737 require.Len(t, timers, 1)740 require.Len(t, timers, 1)
738 timers[0].ch <- time.Time{}741 timers[0].ch <- time.Time{}
739 742
740- require.Equal(t, renderWake{coalesced: 1}, awaitWake(t, h.previews),743+ preview := awaitWake(t, h.previews)
744+ if tc.attach {
745+ require.NotNil(t, preview.attachment)
746+ }
747+ preview.attachment = nil
748+ require.Equal(t, renderWake{coalesced: 1}, preview,
741 "the viewer preview must receive target output without target ACK capacity")749 "the viewer preview must receive target output without target ACK capacity")
742 requireNoWake(t, h.wakes)750 requireNoWake(t, h.wakes)
743 requireNoWake(t, h.previews)751 requireNoWake(t, h.previews)
744 752
745 h.ackReady.Store(true)753 h.ackReady.Store(true)
746 h.rc.notifyAck()754 h.rc.notifyAck()
747- require.Equal(t, renderWake{coalesced: 1}, awaitWake(t, h.wakes),755+ wake := awaitWake(t, h.wakes)
756+ if tc.attach {
757+ require.NotNil(t, wake.attachment)
758+ }
759+ wake.attachment = nil
760+ require.Equal(t, renderWake{coalesced: 1}, wake,
748 "the target primary frame remains pending for its own ACK")761 "the target primary frame remains pending for its own ACK")
749 requireNoWake(t, h.previews)762 requireNoWake(t, h.previews)
750 })763 })

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