detached 7c216c5

fix(daemon): validate coordinator wake incarnation

commit
7c216c5ff58b6bad57d2f0517e3437550caff63d
author
brice · · 2026-07-11 20:23 UTC
committer
brice · · 2026-07-11 20:23 UTC
parent
2c28fcf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +70 -18
M internal/usecase/daemon/client.go +18 -14
@@ -15,14 +15,18 @@ import (
1515 )
1616
1717 type attachedClient struct {
18- tr ports.Transport
19- output *outputStateStream
20- overlays *overlayRuntime
21- overlayOnce sync.Once
22- clientID [16]byte
23- resumeCapable bool
24- resumeToken uint64
25- parked bool
18+ tr ports.Transport
19+ output *outputStateStream
20+ overlays *overlayRuntime
21+ overlayOnce sync.Once
22+ clientID [16]byte
23+ resumeCapable bool
24+ resumeToken uint64
25+ parked bool
26+ // coordinatorEpoch is published atomically by renderCoordinator lifecycle
27+ // transitions. A wake verifies it again after acquiring sendMu, so a parked
28+ // attachment reused by resume cannot carry an old wake to its new transport.
29+ coordinatorEpoch atomic.Uint64
2630 echoAck atomic.Uint64
2731 bars barCache // only touched while sendMu is held
2832 composed composedFrameCache // only touched while sendMu is held
@@ -390,12 +394,12 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient)
390394 rc = newRenderCoordinator(renderCoordinatorOptions{
391395 clock: d.clock,
392396 wake: func(w renderWake) {
393- // w.attachment was validated and captured under the coordinator
394- // lock. Never reread sess.client here: attach publishes that field
395- // before it replaces the coordinator, so doing so can paint the
396- // new connection before Welcome.
397- if w.attachment != nil {
398- d.paint(sess, w.attachment, w.reset)
397+ // Never reread sess.client here: a wake is bound to both its
398+ // attachment and its coordinator incarnation. The second check in
399+ // paint occurs under sendMu, closing a park/resume race after this
400+ // unlocked coordinator validation.
401+ if w.attachment != nil && rc.wakeCurrent(w) {
402+ d.paintCoordinatorWake(sess, w)
399403 }
400404 },
401405 ackReady: func() bool {
@@ -15,14 +15,18 @@ import (
15 )15 )
16 16
17 type attachedClient struct {17 type attachedClient struct {
18- tr ports.Transport18+ tr ports.Transport
19- output *outputStateStream19+ output *outputStateStream
20- overlays *overlayRuntime20+ overlays *overlayRuntime
21- overlayOnce sync.Once21+ overlayOnce sync.Once
22- clientID [16]byte22+ clientID [16]byte
23- resumeCapable bool23+ resumeCapable bool
24- resumeToken uint6424+ resumeToken uint64
25- parked bool25+ parked bool
26+ // coordinatorEpoch is published atomically by renderCoordinator lifecycle
27+ // transitions. A wake verifies it again after acquiring sendMu, so a parked
28+ // attachment reused by resume cannot carry an old wake to its new transport.
29+ coordinatorEpoch atomic.Uint64
26 echoAck atomic.Uint6430 echoAck atomic.Uint64
27 bars barCache // only touched while sendMu is held31 bars barCache // only touched while sendMu is held
28 composed composedFrameCache // only touched while sendMu is held32 composed composedFrameCache // only touched while sendMu is held
@@ -390,12 +394,12 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient)
390 rc = newRenderCoordinator(renderCoordinatorOptions{394 rc = newRenderCoordinator(renderCoordinatorOptions{
391 clock: d.clock,395 clock: d.clock,
392 wake: func(w renderWake) {396 wake: func(w renderWake) {
393- // w.attachment was validated and captured under the coordinator397+ // Never reread sess.client here: a wake is bound to both its
394- // lock. Never reread sess.client here: attach publishes that field398+ // attachment and its coordinator incarnation. The second check in
395- // before it replaces the coordinator, so doing so can paint the399+ // paint occurs under sendMu, closing a park/resume race after this
396- // new connection before Welcome.400+ // unlocked coordinator validation.
397- if w.attachment != nil {401+ if w.attachment != nil && rc.wakeCurrent(w) {
398- d.paint(sess, w.attachment, w.reset)402+ d.paintCoordinatorWake(sess, w)
399 }403 }
400 },404 },
401 ackReady: func() bool {405 ackReady: func() bool {
M internal/usecase/daemon/render.go +9 -3
@@ -278,10 +278,16 @@ func (d *Daemon) invalidateForResizeGeneration(sess *session, ac *attachedClient
278278 }
279279
280280 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
281- d.paintForResizeGeneration(sess, ac, reset, 0)
281+ d.paintForResizeGeneration(sess, ac, reset, 0, 0)
282282 }
283283
284-func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, reset bool, resizeGeneration uint64) {
284+// paintCoordinatorWake composes a coordinator wake only for its captured
285+// attachment incarnation. The epoch is checked after sendMu is acquired.
286+func (d *Daemon) paintCoordinatorWake(sess *session, w renderWake) {
287+ d.paintForResizeGeneration(sess, w.attachment, w.reset, 0, w.attachmentEpoch)
288+}
289+
290+func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, reset bool, resizeGeneration, attachmentEpoch uint64) {
285291 tb := sess.activeTab()
286292 if tb == nil {
287293 return
@@ -294,7 +300,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
294300 sess.mu.Lock()
295301 owned := sess.client == ac
296302 sess.mu.Unlock()
297- if !owned || ac.currentSession() != sess {
303+ if !owned || ac.currentSession() != sess || (attachmentEpoch != 0 && ac.coordinatorEpoch.Load() != attachmentEpoch) {
298304 ac.sendMu.Unlock()
299305 return
300306 }
@@ -278,10 +278,16 @@ func (d *Daemon) invalidateForResizeGeneration(sess *session, ac *attachedClient
278 }278 }
279 279
280 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {280 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
281- d.paintForResizeGeneration(sess, ac, reset, 0)281+ d.paintForResizeGeneration(sess, ac, reset, 0, 0)
282 }282 }
283 283
284-func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, reset bool, resizeGeneration uint64) {284+// paintCoordinatorWake composes a coordinator wake only for its captured
285+// attachment incarnation. The epoch is checked after sendMu is acquired.
286+func (d *Daemon) paintCoordinatorWake(sess *session, w renderWake) {
287+ d.paintForResizeGeneration(sess, w.attachment, w.reset, 0, w.attachmentEpoch)
288+}
289+
290+func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, reset bool, resizeGeneration, attachmentEpoch uint64) {
285 tb := sess.activeTab()291 tb := sess.activeTab()
286 if tb == nil {292 if tb == nil {
287 return293 return
@@ -294,7 +300,7 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
294 sess.mu.Lock()300 sess.mu.Lock()
295 owned := sess.client == ac301 owned := sess.client == ac
296 sess.mu.Unlock()302 sess.mu.Unlock()
297- if !owned || ac.currentSession() != sess {303+ if !owned || ac.currentSession() != sess || (attachmentEpoch != 0 && ac.coordinatorEpoch.Load() != attachmentEpoch) {
298 ac.sendMu.Unlock()304 ac.sendMu.Unlock()
299 return305 return
300 }306 }
M internal/usecase/daemon/render_coordinator.go +36 -1
@@ -60,6 +60,9 @@ type renderWake struct {
6060 attachment *attachedClient
6161 // watchdog marks a flush forced by the synchronized-output watchdog.
6262 watchdog bool
63+ // attachmentEpoch is captured with attachment and must still be current
64+ // before this wake may compose or send.
65+ attachmentEpoch uint64
6366 }
6467
6568 // resizeRequestMetadata is the coordinator-owned latest requested resize
@@ -145,6 +148,9 @@ type renderCoordinator struct {
145148 // attachment is the currently bound client identity; callbacks from any
146149 // other identity are stale and must not mutate coordinator state.
147150 attachment *attachedClient
151+ // attachmentEpoch distinguishes consecutive bindings of the same client
152+ // object (notably park/resume, which replaces its transport in place).
153+ attachmentEpoch uint64
148154 // detached rejects invalidations after detach/park until a new attachment.
149155 detached bool
150156 torndown bool
@@ -537,16 +543,38 @@ func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient) {
537543 c.mu.Unlock()
538544 }
539545
546+// advanceAttachmentEpochLocked invalidates every previously dispatched wake.
547+// It mirrors the epoch onto the client so paint can recheck ownership after it
548+// acquires sendMu without taking c.mu and creating a lock cycle.
549+func (c *renderCoordinator) advanceAttachmentEpochLocked(clients ...*attachedClient) {
550+ c.attachmentEpoch++
551+ for _, ac := range clients {
552+ if ac != nil {
553+ ac.coordinatorEpoch.Store(c.attachmentEpoch)
554+ }
555+ }
556+}
557+
540558 // attach binds ac as the coordinator's current attachment identity.
541559 func (c *renderCoordinator) attach(ac *attachedClient) {
542560 c.mu.Lock()
543561 if !c.torndown {
562+ c.advanceAttachmentEpochLocked(ac)
544563 c.attachment = ac
545564 c.detached = false
546565 }
547566 c.mu.Unlock()
548567 }
549568
569+// wakeCurrent validates a dispatched wake without entering sendMu. The paint
570+// path repeats the epoch check after taking sendMu, closing the remaining
571+// lifecycle handoff window without holding c.mu across composition.
572+func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
573+ c.mu.Lock()
574+ defer c.mu.Unlock()
575+ return !c.torndown && !c.detached && c.attachment == w.attachment && c.attachmentEpoch == w.attachmentEpoch
576+}
577+
550578 // noteDetach invalidates pending wakes and stale callbacks for a detaching
551579 // attachment.
552580 func (c *renderCoordinator) noteDetach(ac *attachedClient) {
@@ -554,6 +582,7 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
554582 var timer ports.Timer
555583 var timers []ports.Timer
556584 if c.attachment == ac {
585+ c.advanceAttachmentEpochLocked(ac)
557586 c.attachment = nil
558587 c.detached = true
559588 c.pending = false
@@ -580,6 +609,7 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient) {
580609 var timer ports.Timer
581610 var timers []ports.Timer
582611 if c.attachment == old || c.attachment == nil {
612+ c.advanceAttachmentEpochLocked(old, replacement)
583613 c.attachment = replacement
584614 c.detached = false
585615 c.pending = false
@@ -602,6 +632,7 @@ func (c *renderCoordinator) notePark(ac *attachedClient) { c.noteDetach(ac) }
602632 func (c *renderCoordinator) noteSessionTeardown() {
603633 c.mu.Lock()
604634 c.torndown = true
635+ c.advanceAttachmentEpochLocked(c.attachment)
605636 c.attachment = nil
606637 c.previewWake = nil
607638 c.previewWakes = nil
@@ -721,7 +752,11 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
721752 c.mu.Unlock()
722753 return
723754 }
724- w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, attachment: c.attachment}
755+ attachmentEpoch := uint64(0)
756+ if c.attachment != nil {
757+ attachmentEpoch = c.attachmentEpoch
758+ }
759+ w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, attachment: c.attachment, attachmentEpoch: attachmentEpoch}
725760 if !ready {
726761 if deadline {
727762 c.ackDeferred = true
@@ -60,6 +60,9 @@ type renderWake struct {
60 attachment *attachedClient60 attachment *attachedClient
61 // watchdog marks a flush forced by the synchronized-output watchdog.61 // watchdog marks a flush forced by the synchronized-output watchdog.
62 watchdog bool62 watchdog bool
63+ // attachmentEpoch is captured with attachment and must still be current
64+ // before this wake may compose or send.
65+ attachmentEpoch uint64
63 }66 }
64 67
65 // resizeRequestMetadata is the coordinator-owned latest requested resize68 // resizeRequestMetadata is the coordinator-owned latest requested resize
@@ -145,6 +148,9 @@ type renderCoordinator struct {
145 // attachment is the currently bound client identity; callbacks from any148 // attachment is the currently bound client identity; callbacks from any
146 // other identity are stale and must not mutate coordinator state.149 // other identity are stale and must not mutate coordinator state.
147 attachment *attachedClient150 attachment *attachedClient
151+ // attachmentEpoch distinguishes consecutive bindings of the same client
152+ // object (notably park/resume, which replaces its transport in place).
153+ attachmentEpoch uint64
148 // detached rejects invalidations after detach/park until a new attachment.154 // detached rejects invalidations after detach/park until a new attachment.
149 detached bool155 detached bool
150 torndown bool156 torndown bool
@@ -537,16 +543,38 @@ func (c *renderCoordinator) teardownPreviewFor(viewer *attachedClient) {
537 c.mu.Unlock()543 c.mu.Unlock()
538 }544 }
539 545
546+// advanceAttachmentEpochLocked invalidates every previously dispatched wake.
547+// It mirrors the epoch onto the client so paint can recheck ownership after it
548+// acquires sendMu without taking c.mu and creating a lock cycle.
549+func (c *renderCoordinator) advanceAttachmentEpochLocked(clients ...*attachedClient) {
550+ c.attachmentEpoch++
551+ for _, ac := range clients {
552+ if ac != nil {
553+ ac.coordinatorEpoch.Store(c.attachmentEpoch)
554+ }
555+ }
556+}
557+
540 // attach binds ac as the coordinator's current attachment identity.558 // attach binds ac as the coordinator's current attachment identity.
541 func (c *renderCoordinator) attach(ac *attachedClient) {559 func (c *renderCoordinator) attach(ac *attachedClient) {
542 c.mu.Lock()560 c.mu.Lock()
543 if !c.torndown {561 if !c.torndown {
562+ c.advanceAttachmentEpochLocked(ac)
544 c.attachment = ac563 c.attachment = ac
545 c.detached = false564 c.detached = false
546 }565 }
547 c.mu.Unlock()566 c.mu.Unlock()
548 }567 }
549 568
569+// wakeCurrent validates a dispatched wake without entering sendMu. The paint
570+// path repeats the epoch check after taking sendMu, closing the remaining
571+// lifecycle handoff window without holding c.mu across composition.
572+func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
573+ c.mu.Lock()
574+ defer c.mu.Unlock()
575+ return !c.torndown && !c.detached && c.attachment == w.attachment && c.attachmentEpoch == w.attachmentEpoch
576+}
577+
550 // noteDetach invalidates pending wakes and stale callbacks for a detaching578 // noteDetach invalidates pending wakes and stale callbacks for a detaching
551 // attachment.579 // attachment.
552 func (c *renderCoordinator) noteDetach(ac *attachedClient) {580 func (c *renderCoordinator) noteDetach(ac *attachedClient) {
@@ -554,6 +582,7 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
554 var timer ports.Timer582 var timer ports.Timer
555 var timers []ports.Timer583 var timers []ports.Timer
556 if c.attachment == ac {584 if c.attachment == ac {
585+ c.advanceAttachmentEpochLocked(ac)
557 c.attachment = nil586 c.attachment = nil
558 c.detached = true587 c.detached = true
559 c.pending = false588 c.pending = false
@@ -580,6 +609,7 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient) {
580 var timer ports.Timer609 var timer ports.Timer
581 var timers []ports.Timer610 var timers []ports.Timer
582 if c.attachment == old || c.attachment == nil {611 if c.attachment == old || c.attachment == nil {
612+ c.advanceAttachmentEpochLocked(old, replacement)
583 c.attachment = replacement613 c.attachment = replacement
584 c.detached = false614 c.detached = false
585 c.pending = false615 c.pending = false
@@ -602,6 +632,7 @@ func (c *renderCoordinator) notePark(ac *attachedClient) { c.noteDetach(ac) }
602 func (c *renderCoordinator) noteSessionTeardown() {632 func (c *renderCoordinator) noteSessionTeardown() {
603 c.mu.Lock()633 c.mu.Lock()
604 c.torndown = true634 c.torndown = true
635+ c.advanceAttachmentEpochLocked(c.attachment)
605 c.attachment = nil636 c.attachment = nil
606 c.previewWake = nil637 c.previewWake = nil
607 c.previewWakes = nil638 c.previewWakes = nil
@@ -721,7 +752,11 @@ func (c *renderCoordinator) fire(gen uint64, watchdog, deadline bool) {
721 c.mu.Unlock()752 c.mu.Unlock()
722 return753 return
723 }754 }
724- w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, attachment: c.attachment}755+ attachmentEpoch := uint64(0)
756+ if c.attachment != nil {
757+ attachmentEpoch = c.attachmentEpoch
758+ }
759+ w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog, attachment: c.attachment, attachmentEpoch: attachmentEpoch}
725 if !ready {760 if !ready {
726 if deadline {761 if deadline {
727 c.ackDeferred = true762 c.ackDeferred = true
M internal/usecase/daemon/render_coordinator_test.go +3 -0
@@ -388,6 +388,7 @@ resize:
388388 wake := awaitWake(t, wakes)
389389 require.Same(t, owner, wake.attachment)
390390 wake.attachment = nil
391+ wake.attachmentEpoch = 0
391392 require.Equal(t, renderWake{coalesced: 1}, wake)
392393 for range 4096 {
393394 select {
@@ -776,6 +777,7 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
776777 require.NotNil(t, preview.attachment)
777778 }
778779 preview.attachment = nil
780+ preview.attachmentEpoch = 0
779781 require.Equal(t, renderWake{coalesced: 1}, preview,
780782 "the viewer preview must receive target output without target ACK capacity")
781783 requireNoWake(t, h.wakes)
@@ -788,6 +790,7 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
788790 require.NotNil(t, wake.attachment)
789791 }
790792 wake.attachment = nil
793+ wake.attachmentEpoch = 0
791794 require.Equal(t, renderWake{coalesced: 1}, wake,
792795 "the target primary frame remains pending for its own ACK")
793796 requireNoWake(t, h.previews)
@@ -388,6 +388,7 @@ resize:
388 wake := awaitWake(t, wakes)388 wake := awaitWake(t, wakes)
389 require.Same(t, owner, wake.attachment)389 require.Same(t, owner, wake.attachment)
390 wake.attachment = nil390 wake.attachment = nil
391+ wake.attachmentEpoch = 0
391 require.Equal(t, renderWake{coalesced: 1}, wake)392 require.Equal(t, renderWake{coalesced: 1}, wake)
392 for range 4096 {393 for range 4096 {
393 select {394 select {
@@ -776,6 +777,7 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
776 require.NotNil(t, preview.attachment)777 require.NotNil(t, preview.attachment)
777 }778 }
778 preview.attachment = nil779 preview.attachment = nil
780+ preview.attachmentEpoch = 0
779 require.Equal(t, renderWake{coalesced: 1}, preview,781 require.Equal(t, renderWake{coalesced: 1}, preview,
780 "the viewer preview must receive target output without target ACK capacity")782 "the viewer preview must receive target output without target ACK capacity")
781 requireNoWake(t, h.wakes)783 requireNoWake(t, h.wakes)
@@ -788,6 +790,7 @@ func TestRenderCoordinatorPreviewWakesDoNotWaitForTargetAck(t *testing.T) {
788 require.NotNil(t, wake.attachment)790 require.NotNil(t, wake.attachment)
789 }791 }
790 wake.attachment = nil792 wake.attachment = nil
793+ wake.attachmentEpoch = 0
791 require.Equal(t, renderWake{coalesced: 1}, wake,794 require.Equal(t, renderWake{coalesced: 1}, wake,
792 "the target primary frame remains pending for its own ACK")795 "the target primary frame remains pending for its own ACK")
793 requireNoWake(t, h.previews)796 requireNoWake(t, h.previews)
M internal/usecase/daemon/resume_wake_incarnation_test.go +4 -0
@@ -53,6 +53,10 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
5353 close(releaseWake)
5454 <-wakeDone
5555 require.Empty(t, newTransport.Sends(), "a stale pre-resume wake must not emit Output before Welcome")
56+ ac.sendMu.Lock()
57+ staleWakeComposed := ac.composed.valid
58+ ac.sendMu.Unlock()
59+ require.False(t, staleWakeComposed, "a stale wake must not mutate the resumed attachment shadow")
5660
5761 // The new attachment incarnation still accepts its normal required paint.
5862 rc.opts.wake = originalWake
@@ -53,6 +53,10 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
53 close(releaseWake)53 close(releaseWake)
54 <-wakeDone54 <-wakeDone
55 require.Empty(t, newTransport.Sends(), "a stale pre-resume wake must not emit Output before Welcome")55 require.Empty(t, newTransport.Sends(), "a stale pre-resume wake must not emit Output before Welcome")
56+ ac.sendMu.Lock()
57+ staleWakeComposed := ac.composed.valid
58+ ac.sendMu.Unlock()
59+ require.False(t, staleWakeComposed, "a stale wake must not mutate the resumed attachment shadow")
56 60
57 // The new attachment incarnation still accepts its normal required paint.61 // The new attachment incarnation still accepts its normal required paint.
58 rc.opts.wake = originalWake62 rc.opts.wake = originalWake

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